Squ1rrel CTF 2025 Go-getter write up
Ans:squ1rrel{fake_flag}
進入網站看看。

可以看到網頁中有兩個選項,一個可以查看 GOpher,另一個則是直接去看 flag,這裡我選擇看看 GOpher 的話,他會幹啥。


可以發現就是隨機出現一些照片而已,接著我去看另一個選項。

會發現我們不能直接查看 flag,因為他有鎖權限,只有 admin 可以查看內容,前端看起來沒啥大問題,所以去 Burp suite 看看。

會看到兩個選項送出時,都會傳一個 POST 封包,內容大致沒有甚麼不同,差別在 MIME type 跟 action 參數內容不同。
Get GOpher:

Get flag:

既然他前端可以送 getflag 進去,但到後端卻被驗證擋下來,所以我就去看他原始碼幹了啥。
main.go 完整原始碼
在前端處理當中,他給的 go 檔中有去做一個呼叫 getflag 的行為,不過他這裡並沒有做到 call python server 的行為,而是在後面直接寫死說我們不是 admin,因此沒辦法看 flag 內容。
反而只有 getgopher 才會被轉發到 python-service:8081/execute,所以看起來 flag 的真正邏輯應該在 Python service,而不是這個 Go service。
換句話說,Go 這邊是個 API Gateway,只允許呼叫 getgopher。
app.py 完整程式碼
在這裡他有明確支援兩個 action,也就是說真正的 flag 是藏在 Python service 裡面,而且只有 action=getflag 才會吐出來。
所以透過上面的觀察,可以得出兩邊 server 的情形:
-
Go service:
getgopher → 會把 body 轉發給 Python。
getflag → 直接寫死 deny。
-
Python service:
getgopher → 回隨機 gopher。
getflag → 回傳環境變數 FLAG。
所以用 Go 那邊 POST getflag 會被拒絕是因為 Go 根本沒把 getflag 轉發,硬生生擋掉了。
這裡我們要思考說:如何讓 Go server 認為我們在執行 getgopher,但同時又可以讓 python server 認為我們在執行 getflag。
在 main.go 的程式碼中,我們可以看到以下內容:
這裡有個顯眼的東西:json.Unmarshal()。
json.Unmarshal() 去解析 JSON → 填進 RequestData 結構。
- 特性是
json.Unmarshal() 會先嘗試用 tag (json:"action") 名稱精確比對,如果找不到,會 fallback 去比對 struct 欄位名稱,而且是大小寫不敏感。
Go 在解析的時候會這樣想:
-
先看 struct 裡有沒有標籤 (json:"action")。
- 標籤說明:「我只吃 action 這個小寫鍵」。
- 所以 Go 會先找 JSON 裡是不是有
"action"。
-
如果找不到呢?那就退而求其次。
- Go 會去看看 struct 本身欄位名字 Action 有沒有出現在 JSON 裡。
- 這裡就不在意大小寫了,
Action、 ACTION、aCtIoN 全都算。
也就是說,不管傳 {"action": "getgopher"} 還是 {"Action": "getgopher"},Go 都會認得。
接著在 app.py 中有這一段程式碼:
這裡 python 進行嚴格的比對:
- Python 的
json.loads() 是嚴格區分大小寫的。
- 只有 key 正好是
action 才會進去後面的文字判斷。
json.loads() 沒出現在程式碼裡,如何知道有 json.loads() 存在?
程式碼裡面確實看不到 json.loads(),因為他是 Flask 幫忙包好的,在 Flask 的流程中有一段程式碼:
get_json() 底下就是:
- 把 HTTP request body 讀出來
- 丟進 Python 標準庫的
json.loads() 去拆解。
準確一點:傳來的 JSON 字串送到 request.get_json(),這時 Flask 呼叫 flask.json.loads() 去解析,flask.json.loads() 會再決定用 simplejson.loads() 或內建的 json.loads() 去把 JSON 轉成 Python dict/list/數字等。
所以雖然沒直接寫 json.loads(),但 Flask 幫你自動用了 Python 的 JSON 解析器,而 Python 這邊就是大小寫完全嚴格。
綜合上述,可以利用兩邊解讀的差異來繞過:
- Go:只要 key 能 map 到
Action 欄位,不管大小寫都算。
- Python:key 必須精確是
action。
所以可以創建兩個 key:

為何 "action": "getflag" 一定要在前面?
- Golang 解析 JSON 會先用 tag 精準比對 key,找不到才用欄位名大小寫不敏感比對。
- 如果同時有
action 和 Action,後出現的會覆蓋前面的值。
- 所以
"action":"getflag","Action":"getgopher" → 最後 struct 裡是 "getgopher"(成功)。
"Action":"getgopher","action":"getflag" → 最後 struct 裡是 "getflag"(失敗,因為不是 admin 所以沒有權限可以看 flag)。
簡單一句話:誰最後寫到 struct,誰就生效。
Squ1rrel CTF 2025 Go-getter write up
進入網站看看。
可以看到網頁中有兩個選項,一個可以查看 GOpher,另一個則是直接去看 flag,這裡我選擇看看 GOpher 的話,他會幹啥。
可以發現就是隨機出現一些照片而已,接著我去看另一個選項。
會發現我們不能直接查看 flag,因為他有鎖權限,只有 admin 可以查看內容,前端看起來沒啥大問題,所以去 Burp suite 看看。
會看到兩個選項送出時,都會傳一個 POST 封包,內容大致沒有甚麼不同,差別在 MIME type 跟 action 參數內容不同。
既然他前端可以送 getflag 進去,但到後端卻被驗證擋下來,所以我就去看他原始碼幹了啥。
main.go 完整原始碼
在前端處理當中,他給的 go 檔中有去做一個呼叫
getflag的行為,不過他這裡並沒有做到 call python server 的行為,而是在後面直接寫死說我們不是 admin,因此沒辦法看 flag 內容。反而只有
getgopher才會被轉發到python-service:8081/execute,所以看起來 flag 的真正邏輯應該在 Python service,而不是這個 Go service。換句話說,Go 這邊是個 API Gateway,只允許呼叫
getgopher。app.py 完整程式碼
if data['action'] == "getgopher": # choose random gopher gopher = random.choice(GO_HAMSTER_IMAGES) return jsonify(gopher) elif data['action'] == "getflag": return jsonify({"flag": os.getenv("FLAG")}) else: return jsonify({"error": "Invalid action"}), 400在這裡他有明確支援兩個
action,也就是說真正的 flag 是藏在 Python service 裡面,而且只有action=getflag才會吐出來。所以透過上面的觀察,可以得出兩邊 server 的情形:
Go service:
getgopher→ 會把 body 轉發給 Python。getflag→ 直接寫死 deny。Python service:
getgopher→ 回隨機 gopher。getflag→ 回傳環境變數 FLAG。所以用 Go 那邊 POST getflag 會被拒絕是因為 Go 根本沒把
getflag轉發,硬生生擋掉了。這裡我們要思考說:如何讓 Go server 認為我們在執行
getgopher,但同時又可以讓 python server 認為我們在執行getflag。在
main.go的程式碼中,我們可以看到以下內容:這裡有個顯眼的東西:
json.Unmarshal()。json.Unmarshal()去解析 JSON → 填進RequestData結構。json.Unmarshal()會先嘗試用tag (json:"action")名稱精確比對,如果找不到,會 fallback 去比對 struct 欄位名稱,而且是大小寫不敏感。Go 在解析的時候會這樣想:
先看 struct 裡有沒有標籤
(json:"action")。"action"。如果找不到呢?那就退而求其次。
Action、ACTION、aCtIoN全都算。也就是說,不管傳
{"action": "getgopher"}還是{"Action": "getgopher"},Go 都會認得。接著在
app.py中有這一段程式碼:@app.route('/execute', methods=['POST']) def execute(): # Ensure request has JSON if not request.is_json: return jsonify({"error": "Invalid JSON"}), 400 data = request.get_json() ...這裡 python 進行嚴格的比對:
json.loads()是嚴格區分大小寫的。action才會進去後面的文字判斷。json.loads()沒出現在程式碼裡,如何知道有json.loads()存在?程式碼裡面確實看不到
json.loads(),因為他是 Flask 幫忙包好的,在 Flask 的流程中有一段程式碼:get_json()底下就是:json.loads()去拆解。準確一點:傳來的 JSON 字串送到
request.get_json(),這時 Flask 呼叫flask.json.loads()去解析,flask.json.loads()會再決定用simplejson.loads()或內建的json.loads()去把 JSON 轉成 Python dict/list/數字等。所以雖然沒直接寫
json.loads(),但 Flask 幫你自動用了 Python 的 JSON 解析器,而 Python 這邊就是大小寫完全嚴格。綜合上述,可以利用兩邊解讀的差異來繞過:
Action欄位,不管大小寫都算。action。所以可以創建兩個 key:
為何
"action": "getflag"一定要在前面?action和Action,後出現的會覆蓋前面的值。"action":"getflag","Action":"getgopher"→ 最後 struct 裡是"getgopher"(成功)。"Action":"getgopher","action":"getflag"→ 最後 struct 裡是"getflag"(失敗,因為不是 admin 所以沒有權限可以看 flag)。簡單一句話:誰最後寫到 struct,誰就生效。