Squ1rrel CTF 2025 Go-getter write up

Ans:squ1rrel{fake_flag}

進入網站看看。

image

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

image
image

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

image

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

image

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

Get GOpher:
 

image

Get flag:

image

既然他前端可以送 getflag 進去,但到後端卻被驗證擋下來,所以我就去看他原始碼幹了啥。

main.go 完整原始碼
package main import ( "bytes" "encoding/json" "io" "log" "net/http" ) // Struct to parse incoming JSON type RequestData struct { Action string `json:"action"` } // Serve the HTML page func homeHandler(w http.ResponseWriter, r *http.Request) { html := `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>What GOpher are you?</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <script> function sendRequest() { const selectedOption = document.querySelector('input[name="action"]:checked'); if (!selectedOption) { alert("Please select an action!"); return; } fetch("/execute", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: selectedOption.value }) }) .then(response => response.text().then(text => ({ text, response }))) .then(({ text, response }) => { var gopherContainer = document.getElementById("gopher-container"); var errorContainer = document.getElementById("error-container"); gopherContainer.innerHTML = ""; errorContainer.innerHTML = ""; try { var data = JSON.parse(text); if (data.flag) { alert(data.flag); } else if (data.name && data.src) { var nameHeader = document.createElement("h3"); nameHeader.textContent = data.name; var gopherImage = document.createElement("img"); gopherImage.src = data.src; gopherImage.className = "img-fluid rounded"; gopherContainer.appendChild(nameHeader); gopherContainer.appendChild(gopherImage); } } catch (error) { errorContainer.textContent = "Error: " + text; errorContainer.className = "text-danger mt-3"; } }) .catch(function(error) { console.error("Error:", error); }); } </script> </head> <body class="container py-5 text-center"> <h1 class="mb-4">Choose an Action</h1> <div class="d-flex flex-column align-items-center mb-3"> <div class="form-check"> <input class="form-check-input" type="radio" name="action" value="getgopher" id="getgopher"> <label class="form-check-label" for="getgopher">Get GOpher</label> </div> <div class="form-check"> <input class="form-check-input" type="radio" name="action" value="getflag" id="getflag"> <label class="form-check-label" for="getflag">I don't care about gophers, I want the flag >:)</label> </div> </div> <button class="btn btn-primary" onclick="sendRequest()">Submit</button> <div id="error-container"></div> <div id="gopher-container" class="mt-4"></div> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> </body> </html>` w.Header().Set("Content-Type", "text/html") w.Write([]byte(html)) } // Handler for executing actions func executeHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Invalid request method", http.StatusMethodNotAllowed) return } // Read JSON body body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read request body", http.StatusBadRequest) return } // Parse JSON var requestData RequestData if err := json.Unmarshal(body, &requestData); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } // Process action switch requestData.Action { case "getgopher": resp, err := http.Post("http://python-service:8081/execute", "application/json", bytes.NewBuffer(body)) if err != nil { log.Printf("Failed to reach Python API: %v", err) http.Error(w, "Failed to reach Python API", http.StatusInternalServerError) return } defer resp.Body.Close() // Forward response from Python API back to the client responseBody, _ := io.ReadAll(resp.Body) w.WriteHeader(resp.StatusCode) w.Write(responseBody) case "getflag": w.Write([]byte("Access denied: You are not an admin.")) default: http.Error(w, "Invalid action", http.StatusBadRequest) } } func main() { http.HandleFunc("/", homeHandler) http.HandleFunc("/execute", executeHandler) log.Println("Server running on http://localhost:8080") log.Fatal(http.ListenAndServe(":8080", nil)) }
switch requestData.Action {
case "getgopher":
    resp, err := http.Post("http://python-service:8081/execute", "application/json", bytes.NewBuffer(body))
	if err != nil {
			log.Printf("Failed to reach Python API: %v", err)
			http.Error(w, "Failed to reach Python API", http.StatusInternalServerError)
			return
        }
		defer resp.Body.Close()

		// Forward response from Python API back to the client
		responseBody, _ := io.ReadAll(resp.Body)
		w.WriteHeader(resp.StatusCode)
		w.Write(responseBody)
case "getflag":
    w.Write([]byte("Access denied: You are not an admin."))
default:
    http.Error(w, "Invalid action", http.StatusBadRequest)
}

在前端處理當中,他給的 go 檔中有去做一個呼叫 getflag 的行為,不過他這裡並沒有做到 call python server 的行為,而是在後面直接寫死說我們不是 admin,因此沒辦法看 flag 內容。

反而只有 getgopher 才會被轉發到 python-service:8081/execute,所以看起來 flag 的真正邏輯應該在 Python service,而不是這個 Go service。

換句話說,Go 這邊是個 API Gateway,只允許呼叫 getgopher

app.py 完整程式碼
from flask import Flask, request, jsonify import random import os app = Flask(__name__) GO_HAMSTER_IMAGES = [ { "name": "boring gopher", "src": "https://camo.githubusercontent.com/a72f086b878c2e74b90d5dbd3360e7a4aa132a219a662f4d83b7c243298fea4d/68747470733a2f2f7261772e6769746875622e636f6d2f676f6c616e672d73616d706c65732f676f706865722d766563746f722f6d61737465722f676f706865722e706e67" }, { "name": "gopher plush", "src": "https://go.dev/blog/gopher/plush.jpg" }, { "name": "fairy gopher", "src": "https://miro.medium.com/v2/resize:fit:1003/1*lzAGEWMWtgn3NnRECl8gmw.png" }, { "name": "scientist gopher", "src": "https://miro.medium.com/v2/resize:fit:1400/1*Xxckk9KBW73GWgxhtJN5nA.png" }, { "name": "three gopher", "src": "https://go.dev/blog/gopher/header.jpg" }, { "name": "hyperrealistic gopher", "src": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcSPNG7wGmWuHcSi7Wkzmht8TSdeXAHOl5edBw&s" }, { "name": "flyer gopher", "src": "https://upload.wikimedia.org/wikipedia/commons/d/df/Go_gopher_app_engine_color.jpg" } ] @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() # Check if action key exists if 'action' not in data: return jsonify({"error": "Missing 'action' key"}), 400 # Process action 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 if __name__ == '__main__': app.run(host='0.0.0.0', port=8081, debug=True)
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 那邊 POST getflag 會被拒絕是因為 Go 根本沒把 getflag 轉發,硬生生擋掉了。

這裡我們要思考說:如何讓 Go server 認為我們在執行 getgopher,但同時又可以讓 python server 認為我們在執行 getflag


main.go 的程式碼中,我們可以看到以下內容:

// Struct to parse incoming JSON
type RequestData struct {
	Action string `json:"action"`
}
...
// Parse JSON
	var requestData RequestData
	if err := json.Unmarshal(body, &requestData); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

這裡有個顯眼的東西:json.Unmarshal()

Go 在解析的時候會這樣想:

  1. 先看 struct 裡有沒有標籤 (json:"action")

    • 標籤說明:「我只吃 action 這個小寫鍵」。
    • 所以 Go 會先找 JSON 裡是不是有 "action"
  2. 如果找不到呢?那就退而求其次。

    • Go 會去看看 struct 本身欄位名字 Action 有沒有出現在 JSON 裡。
    • 這裡就不在意大小寫了,Action ACTIONaCtIoN 全都算。

也就是說,不管傳 {"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() 沒出現在程式碼裡,如何知道有 json.loads() 存在?

程式碼裡面確實看不到 json.loads(),因為他是 Flask 幫忙包好的,在 Flask 的流程中有一段程式碼:

data = request.get_json()

get_json() 底下就是:

準確一點:傳來的 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 這邊就是大小寫完全嚴格。


綜合上述,可以利用兩邊解讀的差異來繞過:

所以可以創建兩個 key:

{
  "action": "getflag",
  "Action": "getgopher"
}

image

為何 "action": "getflag" 一定要在前面?

簡單一句話:誰最後寫到 struct,誰就生效。