【技術分享】Writeup for Web-Checkin in CyBRICS CTF 2021
這是 CyBRICS CTF 2021 中的一個難度為 Hard 的 Web 題(其實是 Crypto 密碼題)。由于作者的某些原因,這個題目在比賽結束都是零解。在比賽結束之后,跟主辦方 battle 了半天,作者終于意識到這個題目有問題,在原題正常的情況下,只有理論上才有解…于是,主辦方在比賽結束后也承認了自己的錯誤:”looks like we’ve shit our pants”,也在直播解題的時候對于此題無解進行了解釋,并放出了一個修復的版本,而且為了表示自己的誠意,主辦方也表示前三個能夠解決這個問題的人可以獲得 bounty 。
于是,我跟我的密碼學小伙伴努力了兩天,終于最后成功地解決了這個題目拿到了二血,也是僅有的兩支獲得獎勵的隊伍之一。以下是這個題目修復后版本的 WriteUp 。
TL;DR
Padding Oracle Attack + Bit Flip Attack + XSS
Reconnaissance
這個題目模擬了一個航班預訂網站,我們可以在那里根據用戶信息生成機票,并上傳機票進行登記。
題目主要有三個 API 接口:
- /order:填表單的一個界面,只有做前端頁面展示,表單數據提交到下面這個 API
- /finalize:根據傳入的參數生成 Aztec Code (一個類似二維碼的碼)。通過在線識別的工具可以知道得到的是一串密文,密文大概長這樣:GLESujRnvL1DfBzExFRDKQ0ZjTrqQgOPuDHKWyu5qhlOpFzn2hw3Dc5dsGLT1jMdwzo24z8h8f2vW6sNINRZa70MLB+mrqY5JVPg5DFygnDmVIUEI6yqkiqaB3fg5RCGeTE6gApiuxZSneallm7kCzIt+au5fZG/f9XXypDLWqM= , base64 解碼不能直接得到 ASCII 明文,從密文格式來看暫時沒有其他更多信息。
- /upload:用來上傳得到的 Aztec Code ,正常情況下如果成功就返回 “you are now registered“ 的響應,表示成功注冊。
后來,我們通過改變 base64 編碼數據的一些字節后發現了一些有意思的現象,比如我們通過修改數據的某個字節得到了一個 “PADDING_ERROR” 的響應。所以我們立即想到,這個題目很可能考的是 Padding Oracle Attack (以下簡稱 POA )。
為了證實我們的想法,我們隨便使用題目生成的一個 Aztec Code ,用 base64 解碼成密文,在倒數第二個密碼文本塊的最后一個字節中 XOR 每一個可能的字節值(0~256),后用 base64 編碼這些修改后的密文,并用 Aztec Code 生成圖片上傳到題目的 upload 接口( python 可以用 aztec_code_generator lib )。在我們收到的 256 個響應中,255 個的狀態碼是 200 ,只有一個響應的狀態碼是 500 。并且在這 255 個響應中,在最后一個字節中用x00進行 XOR 得到一個 “Success“ 的響應,其余254個都是 “PADDING_ERROR“ 的響應。
所以這意味著只有 “Success“ 的響應和 500 的響應在服務器端解密后得到了正確填充的明文。響應 “Success“ 是因為得到的明文是未經修改的原始明文,而返回 500 的響應則是因為解密后的明文經過一定程度的修改而被正確填充,我們可以通過利用這一點獲得原始明文的最后一個字節。
通過不斷向服務器發送修改的密碼文本,然后區分服務器是否回應 “PADDING_ERROR”,我們可以逐個恢復整個明文。這也就是所謂的 Padding Oracle Attack。
Padding Oracle Attack
我們簡單回顧一下 POA 攻擊的相關知識。
首先,我們需要了解什么是 Padding 。
眾所周知,分組加密可以將一個明文/密文分成多個等長的block進行加密/解密操作。在 AES 的情況下, 16 個字節的數據為一個block。使用一些分組加密的操作模式,我們可以重復使用分組密碼的加密/解密操作一些長度超過一個塊的數據。例如,AES-CBC模式可以加密/解密長度為16的倍數的數據。但是如果數據的長度不是分組長度的倍數呢?我們可以使用某種填充方法(padding method),在最后一個塊的末尾添加一些數據,使其成為一個完整的塊。
PKCS#7#PKCS#5_and_PKCS#7)就是最廣泛使用的一種填充方法。PKCS#7 首先計算要填充的字節數(pad_length),然后將 pad_length 個字節附加到最后一個明文塊中,每個字節值都是 pad_length 。解除填充后,解密結果的最后一個字節被提取并解析為 pad_length ,并根據 pad_length 來截斷最后一個組的字節數。這里簡單舉個例子,在 “aaaab\x03\x03\x03” 當中,解除填充后為 “aaaab” 。下面是一個 PKCS#7 填充和解填充的Python實現。
def pad(pt): pad_length = 16 - len(pt)%16 pt += bytes([pad_length]) * pad_length return pt def unpad(pt): pad_length = pt[-1] if not 1 <= pad_length <= 16: return None if pad(pt[:-pad_length]) != pt: return None return pt[:-pad_length]
我們需要注意的是,在解除填充后會對填充進行合法性檢查。這意味著最后一個塊只有存在 16 種情況是可以被認為是有效的,所有其他格式的數據都是無效的,將產生 “PADDING_ERROR“ 響應,這是一個我們將在后面會利用到的一個 Padding Oracle 的表現。
另一點要注意的是,即使明文的長度是區塊大小的倍數,仍然需要填充。在這種情況下,將追加 0x10 個字節,每個字節值為b"x10"。
另外,我們還需要與熟悉一下AES-CBC,這是POA最常見的攻擊場景。
在 CBC 模式下,明文填充后被分成若干個明文塊,每個明文塊在 AES 加密前都會與前一個密碼文本塊進行 XOR ,第一個明文塊與一個隨機生成的初始化向量(IV)進行 XOR ,最后的加密結果是以IV為首、其他密文塊連接而成的密文,解密只是逆序進行了這些操作。

AES-CBC 的一個重要缺點是,它并不提供完整性保護。換句話說,攻擊者可以通過某種方式(如字節翻轉)修改密文并將修改后的密碼文本發送到服務器而不被發現,這就為 POA 攻擊提供了條件。
現在,我們可以深入了解 POA 是如何具體進行攻擊的。
假設攻擊者擁有一個密碼文本,它可以分為一個 IV 和 3 個密碼文本塊 c1 、 c2 、 c3 ,攻擊者的目的是要解密最后一個密碼文本塊 c3 。
攻擊者可以改變 c2 的最后一個字節(XOR 上一些字節值),然后將其發送給服務器,我們應當可以得到兩種響應,一種是 200 響應,內容為 “PADDING_ERROR“ ,另一種是 500 響應。如果我們得到一個 500 的響應,說明我們就成功了,這意味著解除填充的檢查通過了,最后一個純文本塊必須以b"\x01"結尾,因為這是 16 種有效的填充格式之一。

在恢復了最后一個字節后,我們可以繼續解密最后一個明文塊之前所有的字節。例如,為了解密倒數第二個字節,我們可以利用b"\x02\x02"的填充格式。由于我們已經知道了明文的最后一個字節,我們可以通過在c2中 XOR 一些字節值將最后一個字節修改成我們想要的任何值。目前,我們想讓最后一個字節變成b"\x02",我們將c2的最后一個字節與明文的最后一個字節進行 XOR ,使其變成b"\x00",然后再 XOR 上b"\x02",結果就是b"\x02"。隨后,嘗試每一個可能的 255 個字節值guess_byte XOR b"\x02"(除了b"\x00")與c2的倒數第二個字節進行XOR,并將修改后的密碼文本發送到 Padding Oracle ,直到得到 500 響應,從而恢復最后第二個明文字節,這正好是guess_byte。
以下是Python代碼,可用于解密最后一個明文塊。
import requestsimport base64
import aztec_code_generator
# padding_oracle recovers the last 16 plaintext bytes of the given ciphertextdef padding_oracle(cipher): plaintext = b"" for index in range(1, 17): print(f"[*] index: {index}") for byte in range(0, 256): bytes_xor = b"\x00"*(16-index)+bytes([byte^index])+xor(plaintext,bytes([index]*(index-1))) new_cipher = cipher[:-32] + xor(cipher[-32:-16], bytes_xor) + cipher[-16:]
b64data = base64.b64encode(new_cipher) code = aztec_code_generator.AztecCode(b64data) code.save(f"./pics/{byte}.png", module_size=4)
f = open(f"./pics/{byte}.png", "rb").read() paramsMultipart = [('file', ('1.png', f, 'application/png'))] response = session.post("http://207.154.224.121:8080/upload", files=paramsMultipart)
if response.status_code == 200: body = response.content.split(b'
')[1].split(b"div")[0]
if b"PADDING" in response.content: print(f"[{byte:>3d}] Status code: {response.status_code}, PADDING ERROR") else: print(f"[{byte:>3d}] Status code: {response.status_code}, {body}") else: # response.status_code == 500 print(f"[{byte:>3d}] Status code: {response.status_code}") plaintext = bytes([byte]) + plaintext print(f"plaintext: {plaintext}") break return plaintext
Recovering the Entire Plaintext
通過利用 Padding Oracle ,我們能夠逐字節解密最后的明文塊。我們還能再進一步利用嗎?答案是肯定的。
一旦我們恢復了最后一個明文塊,我們就可以扔掉最后一個密文塊,并繼續利用 Padding Oracle 來恢復倒數第二個明文塊,以此類推,我們將恢復整段明文數據。
我們按照上述思路實施了攻擊,并成功地恢復了整個明文,并發現這是一個json格式的數據。
b'{"name": "12321", "surname": "123", "middle": "1", "time": "2021-07-26 13:37:00", "dest": "", "dep": "", "flight": "BLZH1337"}\x02\x02'
到這里,我們就可以猜到服務器端是如何處理上傳的 Aztec Code 。在收到圖片數據后,服務器將其解碼為密文,對密文進行解密,并對解密結果解除填充。如果在解除填充過程中發生了錯誤,服務器會以 “PADDING_ERROR“ 的響應來回答。在解除填充后,明文會被進一步處理,可能會通過類似JSON.parse() 的處理。如果處理過程中產生任何錯誤,服務器會以 500 狀態碼來響應;如果一切正常,服務器會給我們發回一個 “Success“ 的 200 響應。
Arbitrary Plaintext Encryption
恢復整個明文并不足以解決這道題目,我們需要進一步構造我們想要的任意明文的密文,也就是說構造一個密文,讓解密結果得到任意我們想要的明文。
為了實現這一目標,我們需要將字節翻轉與 POA 相結合,字節翻轉攻擊使我們能夠將明文改變成我們想要的,而 Padding Oracle 可以作為一個解密器使用,幫助我們解密任何密文。
假設密文 IV || c1 || c2 || c3 解密為 p1 || p2 || p3 ,我們想得到 p1'|| p2'|| p3 的密文。

我們首先將 c1 與 p2 XOR p2' 進行XOR,得到 c1' 。這樣,IV || c1'|| c2 || c3'將被解密為junk || p2' || p3'。

生成的垃圾數據是完全隨機的,這對我們來說是不可控的,而且含有不可控的垃圾數據會影響到 JSON.parse() 的解析,如果有不可見字符服務器會解析出錯,并返回500響應碼。那么,我們能用它做什么呢?還記得恢復最后一個明文塊的 POA 嗎?我們可以重新使用 POA 來恢復垃圾數據塊junk。之后,我們再用 junk XOR p1 來XOR “IV”,得到一個新的 “IV”。這樣,IV'||c1'||c2||c3'將被解密為p1' || p2' || p3,這正是我們想要的!

The XSS Part
后面的 XSS 部分就是白給了。目前我們現在可以加密我們任何想要的東西,接下來我們應該怎么做呢?根據題目的描述,我們必須去獲取一個監控系統的內容,并從中獲得 Mr.Flag Flagger 的信息。并且結合題目給了一個上傳掃碼的接口以及明文塊是 JSON ,很明顯的一個 XSS 題目了,接下來就是我們如何構造這個 XSS Payload 了。
首先,我們得把生成密文的 API 參數與 JSON 參數的對應關系找出來。這個我們可以通過在 API 參數傳入一些易于區分的數據即可,如下:
URL:http://207.154.224.121:8080/finalize?lastName=1&firstName=2&origin=3&Gender=4&destination=5
CipherText:8BAHi37U69MYAnP4O4cHrpRIJrT3dKwv7uRCoLYzU2vnxEOCb6vT0LffcAROX3jPZ+p4yDtKRXwcxYF9B22a3PH3m9tIiEDc3OrwR9W/ACyIcPw7XEJKAyB3QlHiFn2j0HC8P8SpwFqe4A/NRCESLI996IzP9Rkw066eGSuK0MxhpBXGV2gqfm4FAgqTLE3N
PlainText:b'{"name": "2", "surname": "1", "middle": "4", "time": "2021-07-26 13:37:00", "dest": "5", "dep": "3", "flight": "BLZH1337"}'
所以我們基本可以得到如下的對應關系:
- lastName: surname
- firstName: name
- origin: dep
- Gender: middle
- destination: dest
但是我們應該把 XSS Payload 放在哪兒呢?雖然我們可以一個一個嘗試,但是畢竟太麻煩了,仔細觀察題目頁面內容我們可以大概找到如下一個提示(雖然看起來并不算什么提示):
所以,我們可以嘗試把 XSS Payload 注入到 JSON 的 Name 參數當中,例如:
{"name": "", "surname": "1", "middle": "4", "time": "2021-07-26 13:37:00", "dest": "5", "dep": "3", "flight": "BLZH1337"}
但是我們需要注意的是,根據以上密碼學知識,我們首先要在對應的生成密文的 API 處,生成一個 Name 長度與我們 XSS Payload 長度相同的密文,這樣才能不至于解密出錯。例如我們這里的 XSS Payload 長度為 40 ,所以我們也要生成一個 Name 參數長度為 40 的密文,也就是需要我們首先在生成密文的 API 傳入一個長度為 40 的 firstName 參數,并且為了保證其他參數加載到頁面時保證頁面正常,我們最好不要改動其他字段,讓其他字段保持默認值即可。(血淚教訓
URL:http://207.154.224.121:8080/finalize?lastName=1&firstName=0000000000000000000000000000000000000000&origin=3&Gender=4&destination=5
PlainText:b'{"name": "0000000000000000000000000000000000000000", "surname": "1", "middle": "4", "time": "2021-07-26 13:37:00", "dest": "5", "dep": "3", "flight": "BLZH1337"}'
在我們得到密文后,我們接下來就需要使用 Padding Oracle 和字節翻轉來改變密文對應的明文, 然后使用 base64 編碼一下,再用 Aztec Code 編碼轉成圖片,并通過 upload API 上傳圖片即可。最后,終于打到了 Admin !

拿到 Admin Cookie 以及對應頁面內容之后,我們可以直接用 Admin Cookie 登錄到該頁面。登錄之后,發現該頁面只有一個搜索的功能,一開始我還以為是個套娃題,還要 SQL 注入,結果最后按照題目提示搜索了一下 Flagger 就拿到 flag 了…

整體來說是個密碼題,跟 Web 沒多大關系~希望密碼學選手看完后有所收獲23333(因為 Web 部分純白給,Web 選手就是幫檢查檢查哪里出錯了,然后跑跑 exp ,等等 exp 就行了,該說不說,跑個 exp 還得跑個 1.5 小時,確實折磨~