##### tags: `DownUnderCTF_2025`
# DownUnderCTF 2025 WEB : gomail
<style>
.red {
color: red;
}
.blue {
color: blue;
}
.purple {
color: #7D3382;
}
.light_purple {
color: #810cf5;
}
</style>
<span class=""></span>
## <span class="red">題目</span>
- 
## <span class="red">解題過程 (After Event)</span>
1. **根據題目看起來這題是要拿到 `MC Fat Monke` 的 mail (裡面有 flag) , Instance 看起來沒有布置任何的 html ,所以直接進行 Code Review 。本題結構如下,是用 golang 寫的**
- 
- **本題架構:**
```text=
gomail
└── app
├── session
│ ├── claims_test.go
│ ├── claims.go
│ ├── session_test.go
│ └── session.go
├── go.mod
├── go.sum
├── handlers.go
├── main.go
├── middleware.go
└── util.go
```
 
2. **本身對 golang 並不了解,但可以從 `main.go` 看出要互動的路由端點 (Endpoint) 主要有兩個,分別是 `/login` 和 `/emails` 。詳細 code 的內容會跳過,重點放在 exploit 的 code section**
- 
 
3. **首先 `LoginHandler` 在發送 `POST request` 後會執行,內容如下。大意上就是會將拿到的 JSON 讀成寫好的 `SessionClaims` 結構 (`BindJSON()` 會自動從 `Content-Type: application/json` 的 body 解析 JSON) ,利用 `ConstantTimeCompare()` 拿輸入的密碼和隨機產生的 `64bytes(=512bits)` 做比較(如下圖),避免因為執行時間差異 `(side-channel attack)` 而洩漏密碼**
- 
- 
 
4. **接續 step3 ,比對完之後如果密碼正確就會將 `isAdmin` 設定為 true ,反之則將 `request` 中 `Email` 這個欄位的 value 強行轉換為 `guestEmail` 。在這之後就會包裝一個 token 並放在 `response` 內,用來做之後 `GET /emails` 時的身分證明,到這裡 `r.POST("/login", LoginHandler)` 的行為就做完了**
```go=
func LoginHandler(c *gin.Context) {
...
if usrPass != nil {
if subtle.ConstantTimeCompare([]byte(lr.Password), usrPass) == 1 {
isAdmin = true
} else {
lr.Email = guestEmail
}
}
sH, exists := c.Get("sessionHandler")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "could not get session handler",
})
return
}
token, err := sH.(session.Session).Encode(lr.Email, isAdmin)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err,
})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
})
}
```
 
5. **接下來簡單說明 `r.GET("/emails", SessionMiddleware(), GetEmailsHandler)` 做了什麼。首先,在 `GET()` 中會發現他多放了一個 `SessionMiddleware()` 這個參數,而這是 `middleware` 中定義的一個針對請求的預處理,包括請求的 header 中有沒有 `X-Auth-Token` (也就是剛才的 token) , session 欄位中的比對有沒有正確 ( `session.go` 中有定義 `.Decode()` 的檢查方法)**
- 
 
6. **step5 預處理完之後接著就會送到 `GetEmailHandler` 進行處理,這裡內容相對簡單一點,會分別檢查 `sessionClaims` (內含 `Email, Expiry, isAdmin`) 以及 `isAdmin` 的值,根據這個值回傳對應身分的信件。這些是大概的流程,實作細節(像是檢查方法之類的)會在介紹 pwned script 時一起解釋**
- 
 
7. **先講一下比賽當時我做過哪些調查,當時丟網路上查相關攻擊技巧時,看到有人寫了有關於<span class="red">用字串比對 `(==)` 取代恆定時間比較 `(crypto/subtle.ConstantTimeCompare)`</span> 造成的<span class="red">旁通道攻擊 `(CVE-2023-32691)`</span> ,當時一直再找相關漏洞利用結果回頭一看才發現,作者用的是對的!!好吧,但是又學到一招, try harder**
- 
 
8. **接著我又有一計,當我看到 token 是由 `sessionClaims` 和 `sign (簽章)` 組成,所以當時我就想要<span class="red">嘗試偽造 token</span> ,我只改 `sessionClaims` ,讓 `Email` 和 `isAdmin` 欄位都變成我要的值難道不行嗎?於是我就偽造了 `sessionClaims` 的部分,再加上原本的 sig 。結果試了半天都沒有成功,回去檢查 code 在 `SessionMiddleware()` 中用了 `.Decode` ,而這個 function 在 `session.go` 中有定義會檢查 `HMAC hash` 值有沒有相同,也就是說因為<span class="red">原本的 `sign` 與我修改過後經過 `HMAC hash` 的值不同</span>,所以最終導致報錯**
- 
```python=
url = "https://web-gomail-3f344244ceb2.2025.ductf.net/"
s = requests.Session()
""" Based on handlers.go
type loginReq struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
"""
json_data = {
"Email" : "mc-fat@monke.zip",
"Password" : "123" # doesn't matter
}
login_resp = s.post(url = url + "/login", json = json_data)
# print("Login response in JSON:\n")
# print(login_resp.json())
token = login_resp.json()["token"]
print("\n[*] Token is :", token)
# analyze token
body_b64, sig_b64 = token.split(".")
compressed = base64.urlsafe_b64decode(body_b64 + "==")
print("\n[*] body with base64 :", body_b64)
print("\n[*] signature with base64 :", sig_b64 )
# unzip gzip
buf = BytesIO(compressed)
with gzip.GzipFile(fileobj=buf, mode='rb') as f:
raw = f.read()
# analyze structure, email (2 bytes) + email (content) + expiry (8 bytes) + isAdmin (1 Bytes)
# H = uint16
email_len = struct.unpack("<H", raw[:2])[0]
email = raw[2:2 + email_len].decode()
# Q = uint64
expiry = struct.unpack("<Q", raw[2 + email_len:2 + email_len + 8])[0]
isAdmin_byte = raw[-1:]
print(f"\n[*] email = {email}")
print(f"[*] expiry = {expiry}")
print(f"[*] isAdmin_byte = {isAdmin_byte}")
new_isAdmin = b"t"
new_raw = raw[:-1] + new_isAdmin
# compress gzip
buf = BytesIO()
with gzip.GzipFile(fileobj=buf, mode='wb') as f:
f.write(new_raw)
new_compressed = buf.getvalue()
# base64 encode, use origin sig_b64
new_token = base64.urlsafe_b64encode(new_compressed).rstrip(b"=").decode() /
+ "." + sig_b64
resp = s.get(url + "/emails", headers = {
"X-Auth-Token": new_token
})
print("[*] /emails response:")
print(resp.text)
```
- 
- 
- 
 
9. **那麼這題應該怎麼打呢?這題的關鍵在於 `claims.go` 中, `Email` 的值是如何儲存的。如圖,他會<span class="red">將傳入值的長度直接丟給</span> `WriteLength()` ,然後讓 `el` 根據 `uint16` 的傳入值開一個相同大小的 `byte array` 來存放資料。也就是說,這題的關鍵在於<span class="red">利用 `uint16` 的大小上限</span>來做到 truncation (截斷)直接覆寫 `IsAdmin` 的值,最後成功產出 admin 身分的 token**
- 
- 
- 
 
10. **payload 具體要塞甚麼呢?首先總長度一定要大於 uint16 的大小,所以至少要 `65536` 起跳 (`uint16` 範圍是 `0 ~ 65535`) ,接著多塞入的 bytes 是會被 `session.go` 的 `.Encode` 中呼叫的 function 讀入並且存起來的。 payload 如下:**
- **如果塞入內容總共 65536 bytes ,在 `claims.go` 的 `WriteLength` 函式就會如下:**
- **`el := uint16(65536)` = `el := uint16(1) (truncation)`**
- **也就是說,針對 email 的部份目標帳號 `mc-fat@monke.zip` 總共 16 bytes ,就一共需要注入 `65536 + 16 = 65552` bytes ,這樣子在 `Claim.go` 的 `Serialize` 中 Email 欄位就會是 16 bytes**
- **另外針對 `expiry` 和 `isAdmin` ,下面的結構可以很明顯看出,當我設計了一個錯的 `EmailLen` 時,後續的 bytes 會接續被影響(代表也可以被覆蓋)。同時 `expiry` 的檢查方式如下,也就是說只要 `expiry` 比 `當前時間` 還要大的時間都可以通過檢查(不會檢查這個時間是否離現在太遠)**
- **結構: `[EmailLen:2][Email][Expiry:8][IsAdmin:1]`**
- **檢查時會經過 `LittleEndian (低到高)` ${\rightarrow}$ `uint64` ${\rightarrow}$ `int16` 最後才是與 `time.Now().Unix()` 比較**
- 
- **如果今天 `expiry` 欄位是 ```b`t` * 8``` :**
- **`b"tttttttt" = [0x74, 0x74, 0x74, 0x74, 0x74, 0x74, 0x74, 0x74]`**
- 
- 
- 
- 
- **flag🚩 :`DUCTF{g0v3rFloW_2_mY_eM41L5!}`**
- 