VNCTF2026 Re
VNCTF2026 Re 复现
做alictf去了 没怎么做 简单看看题
ez_maze
难度:签到
主要是壳上了 exeinfo上看没有壳 die识别出了是个upx 但是010没找到相关特征,应该是魔改了,使用各种脱壳工具无法直接脱壳,需要手脱
大致看了下程序首先会jmp到102A,102A会jmp到E9D0
入口点:ImageBase + 0x31000(也就是 .arch 节)
.arch开头看起来像乱码,但它先 jmp到 0x3102A,那里是一条jmp rel32,也就是跳到.rdata 内部 RVA 0x1E9D0(这段才是真壳代码)。
在 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函数地址表
F7跟进到如下地址
可以参考upx手动脱壳 - Meteor_Kai - 博客园
此处dump+fix (但不知道为什么修完后的无法运行 显示缺少某dll)
但是字符串却没搜到
因为现在看到的这个字符串是 UTF-16LE(宽字符),而在 IDA 里 Shift+F12常用的搜索很多时候只在 ANSI/ASCII 结果里找
然后一个简单迷宫题
VNCTF{wwaaaaaaaawwwwddwwddwwaaaawwaaaaaassssaawwwwaawwwwwwwa}
Login
前端有
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):
取 android_id = Settings.Secure.getString(…, “android_id”)
拼串:plain = f”{payload}:{android_id}”
自定义填充:j1 = (16 - (len(plain) & 0xF)) & 0xF
追加 0x01 * j1
再写一个 0x00 终止符,但 长度返回的是 len(plain)+j1(不包含 0x00)
AES-128-ECB(key = byte_621A0)
自定义 Base64 字母表:RSTUVWLbcdefghiMNOPrstuvQXYZajCklmnEFGHIJKwxyz01ABD234opq56789+/
sign:sub_26408
- 同样拿 android_id
- 三个字节 byte_621B0..2 都是 0xFF(&1 后都是 1)
- 生成字符串:VNCTF:{payload}:{android_id}:{encrypted}:1:1:1
- md5 -> hex
流量包里可以看
/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 | #!/usr/bin/env python3 |
VNCTF{32_7R4f1Ic_LOGin_SNjWxxn4}
delicious_obf
好大一坨
这是典型的控制流平坦化入口指令序列,本质是计算一个目标地址,然后用 jmp r10 间接跳转
xor 后结果固定是 0x4,所以 r10 固定变成 loc_140006560
还有个jz/jnz的花指令
1 | mov esi, 0x4C552200 |
可以发现跟之前一样
0x1400060BA+4=0x1400060BE
- 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 | import ida_segment |
ida_bytes.get_dword()读出来的是无符号数
FC FF FF FF 这在 x86 指令语义里表示:rel32 = -4
1 | call rel32 |
CPU 做的是:target = next_ip + (int32_t)rel32;
所以要把0xFFFFFFFC还原成-4
建议使用如下的判断方式
1 | if offest1 & 0x80000000: |
记录一下出题人的脚本
1 |
|
addr = offest1 + 4 + 7 - 5 本质是在算把这段lea/add/jmp r10改成一条 E9 rel32 近跳时,rel32 应该填多少
对应关系是:
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=101mod = 00且rm = 101不是绝对地址,而是:[RIP + disp32]所以是相对寻址RIP 不是当前指令的地址 ,RIP = 当前指令地址 + 当前指令长度
mov r11d, A; xor r11d, B 的结果通常是常数,这里就是 4,所以 real_target = lea_target + 4 = current_addr + 7 + offest1 + 4
要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
执行脚本过后还是有很多一大块一大块的数据段 我们可以对其取消定义转为数据先(当然也可以用到时再处理)
官解:
但这里可能有人会有误解,以为一定要从54BB开始,实际上我们可以选择这个函数开始
我们对其上述操作后,如果反编译失败,一层层进去找,中间会有分支比如分支到54BB的这个函数中,这时候就是对54BB这个函数做这样的处理,搞了十几分钟差不多能处理完
但不清楚为什么效果没有出题人处理的这么好 起码能看了
还发现
发现还有个引用(没有的话说明你之前代码恢复的不全)
或者我们手动patch到一起也行
还发现那个脚本没有去除这里的混淆
修复技巧
这里乱七八糟的头其实有问题 应该以push rbp为头部
需要手动检查
然后这下面这个我看了下我这部分的代码搞不出来不知道什么情况 这里的话应该是个SMC
1 |
|
Shadow
VNCTF2026-Shadow-WP | Liv’s blog
Maze.exe一个简单迷宫
sys分析下
Findcrypt发现AES
sub_14000C000从全局 Pool 拷贝 0x5E00 字节,然后调用 sub_140001168 做 16 字节分组变换
确认是 AES 轮函数(S-box 在 0x140004000,Rcon 在 0x140003070,密钥在 dword_14000A000)
静态分析
AI搓个脚本
1 | import argparse |
解密PE文件后,进行PE拉伸、重定位修复、IAT修复、调用DriverEntry,一系列操作进行手动加载该PE文件到内存中运行,具体实现原理可以网上搜”反射注入”,实际就是手动实现加载并运行程序。from liv
关注sub_140001C10函数
sub_140001C10中有个解码字符串函数
1 | def decode_obf(data, key): |
在 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 传进来)。
它主要做这几件事:
- PsLookupProcessByProcessId 拿到目标进程(Maze)的 PEPROCESS。
- 分配一个上下文结构(0xC0),调用 sub_140003B50 做页表相关准备(含保存原始页表项、必要时处理大页/重映射)。
- 用 sub_140002470 从目标函数入口开始反汇编,累计到至少 14 字节(保证覆盖完整指令)。
- 保存原始前导字节,并构造 trampoline(原始字节 + 跳回 原函数+len),把 trampoline 指针写到 *a5。
- 构造 14 字节跳板并写回目标函数入口(sub_140003E80):
FF 25 00 00 00 00 + 8字节hook地址(这里是 sub_1400012C0)。 - 把上下文挂到全局链表(供卸载时恢复),成功返回 1;任一步失败返回 0。
然后对该进程,进行对KeDelayExecutionThread函数单独隔离的Pte hook,Pte hook的特性就是对ntdll函数进行hook,但仅对该进程生效,hook替换成另一个函数。PTE Hook:一种利用页表重映射攻击实现的内核函数Hook-先知社区
键盘数据处理
IoAttachDevice(DeviceObject, &TargetDevice, &AttachedDevice)这说明它把自己挂到键盘类设备栈上,做键盘过滤
Sub_140001100 是这个驱动专门处理 IRP_MJ_READ(读请求)的分发函数,本质是带回调的转发器
核心回调就是CompletionRoutine函数
返回缓冲按 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”(同样先解码)。
交叉引用该函数可以到sub_1400012C0
分析逻辑可得解密代码:
1 | import ida_bytes, ida_name |
或者直接重建在当前文件
1 | import os |
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
密文
但是上面的那些不知道在干什么交叉引用发现实际对密文也进行了异或
1 | MASK32 = 0xFFFFFFFF |
ebbc8827-c040-4a7d-8bc7-0aeccb1ce094
动态分析
不太了解内核逆向
参考[VNCTF 2026]Shadow-驱动dump修复+反射注入+ptehook - Qmeimei’s Blog | 探索一切,攻破一切
师傅写的很详细
1 | kd> sxe ld Shadow.sys |
断再这里
sxe= Set Exception (on) Event当某个指定的“调试事件”发生时,让调试器中断下来。
这里的rcx就是载入驱动的首地址
1 | r rcx |
查看rcx寄存器
1 | db rcx L10 |
d = display memory
b = byte(按字节显示)
rcx = 起始地址
L10 = 显示 0x10 个字节(16字节)
输出有
1 | 10.00 subsystem version |
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
DLL注入
之前学过但是得详细了解下,这里再补充一下
《逆向工程核心原理》之DLL注入 - Zer0o - 博客园
UE4逆向初探-OverWatch | Matriy’s blog
DLL注入指的是向运行中的其他进程强制插入特定的DLL文件。从技术细节来说,DLL注入命令其他进程自行调用LoadLibrary() API,加载用户指定的DLL文件。DLL注入与一般DLL加载的区别在于,加载的目标进程是其自身或其他进程。
向某个进程注入DLL时主要使用以下三种方法:
- 创建远程线程(CreateRemoteThread() API)
- 使用注册表(AppInit_DLLs值)
- 消息钩取(SetWindowsHookEx() API)
CreateRemoteThread
基本原理:
OpenProcess()获取目标进程句柄VirtualAllocEx()在目标进程中分配内存WriteProcessMemory()写入 DLL 路径CreateRemoteThread()调用LoadLibraryA/W
案例中的myhack.dll
1 |
|
在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 |
|
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 | hMod = GetModuleHandle("kernel32 .dll"); |
调用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 | HANDLE CreateRemoteThread( |
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 | CreateFile (kernel32) |
在目标进程中运行远程线程(Remote Thread)
1 | hThread = CreateRemoteThread(hProcess, NULL, 0, pThreadProc, pRemoteBuf, 0, NULL); |
一切准备就绪后,最后向notepad.exe发送一个命令,让其调用LoadLibraryW() API函数加载指定的DLL文件即可,遗憾的是Windows并未直接提供执行这一命令的API。但是我们可以另辟蹊径,使用CreateRemoteThread()这个API (在DLL注入时几乎总会用到)。
AppInit_DLLs
进行DLL注入的第二种方法是使用注册表,WindowsOS的注册表中默认提供了AppInit_DLLs与LoadAppInit_DLLs两个注册表项
只要将要注入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 | // myhack2.cpp |
被注入的进程时64位,那么注入的DLL也应该是64位,32位对应32位。
1 | # 将下面注册表的键对应的值设置为要注入的 DLL的路径 |
注入64位进程,应该修改的注册表键为:
1 | # 将下面注册表的键对应的值设置为要注入的 DLL的路径 |
Windows消息钩取
DLL注入-Windows消息钩取 - Zer0o - 博客园
敲击键盘时,消息会从OS移动到应用程序,而消息钩子就是在这个过程中偷看信息
常规Windows消息流:
- 发生键盘输入事件时,WM_KEYDOWN消息被添加到[OS message queue];
- OS判断哪个应用程序中发生了事件,然后从[OS message queue]中取出消息,添加到相应应用程序的[application message queue]
- 应用程序监视自身的[application message queue],发现新添加的WM_KEYDOWN消息后,调用相应的事件处理程序处理。
附带钩子的信息流:
- 发生键盘输入事件,WM_KEYDOWN消息被添加到OS消息队列;
- OS判断哪个应用程序发生了事件,从OS消息队列中取出消息,发送给应用程序;
- 钩子程序截取信息,对消息采取一定的动作(因钩子目的而定);
- 如钩子程序不拦截消息,消息最终传输给应用程序,此时的消息可能经过了钩子程序的修改。
SetWindowsHookEx()
在Windows中可以使用SetWindowsHookEx()API来设置消息钩子,这个函数除了可以设置当前进程的钩子之外,它还可以设置全局钩子。全局钩子,顾名思义,即当前正在运行的进程都会被设置相应的钩子。
Windows API作用类似是一个个功能函数。
1 | HHOOK SetWindowsHookExA( |
第一个参数表征钩子的类型,但钩子的类型是微软规定好的,你只能选一种,自己不能乱写
第二个参数是钩子执行程序,即当钩子勾到所需信息时运行的程序
第三个参数是要注入的dll句柄
第四个参数是想要挂载的线程ID,如果该参数为0,则表明钩子是一个全局钩子
HHOOK:返回值,钩子句柄,需要保留,等不使用钩子时通过UnhookWindowsHookEx函数卸载钩子。
idHook:钩子的拦截消息类型,选择钩子程序的拦截范围,具体值参考文章结尾的消息类型。
Lpfn:消息的回调函数地址,钩子子程的地址指针,一般是填函数名。
hMod:钩子函数所在的实例的句柄。对于线程钩子,该参数为NULL;对于系统钩子,该参数为钩子函数所在的DLL句柄。在dll中可通过AfxInitExtensionModule(MousehookDLL, hInstance)获得DLL句柄。
dwThreadId:钩子所监视的线程的线程号,可通过GetCurrentThreadId()获得线程号。对于全局钩子,该参数为NULL(或0)。
使用SetWindowsHookEx()设置好钩子后,在某个进程中生成指定消息时,OS会将相关的DLL文件强制注入相应的进程,然后调用注册的钩子过程。
KeyHook.dll文件是一个含有钩子过程(KeyboardProc)的DLL文件,HookMain.exe是最先加载KeyHook.dll并安装键盘钩子的程序。HookMain.exe加载KeyHook.dll后使用SetWindowsHookEx()安装键盘钩子;若其他进程(如图中所示)发生键盘输入事件,OS就会强制将KeyHook.dll加载到像一个进程的内存,然后调用KeyboardProc()函数。
keyHook.cpp
1 | //KeyHook.cpp |
因为要生成的是KeyHook.dll文件,因而在开始创建项目时应先选择Win 32控制台应用程序
当调用导出函数HookStart()时,SetWindowsHookEx()函数就会将KeyboardProc()添加到键盘钩链。安装好键盘钩子后,无论哪个进程,只要发生键盘输入事件,OS都会强制将KeyHook.dll注入相应的进程中。
KeyboardProc()函数中发生键盘输入事件时,会比较当前进程名称和“notepad.exe”是否一致,若一致则返回1,终止KeyboardProc()函数,即截获并删除消息,从而实现对notepad.exe程序的键盘输入事件进行钩取并截获删除、键盘消息不能传递到notepad.exe的消息队列中。
KeyboardProc()函数定义如下:
1 | LRESULT CALLBACK KeyboardProc( |
其中wParam指用户按下的键盘按键的虚拟键值。
HookMain.cpp
1 | //HookMain |
安装好键盘钩子后,无论在哪个进程中,只要发生了键盘输入事件,OS就会强制将KeyHook.dll注入到进程中,加载了KeyHook.dll的进程,发生键盘事件时会首先调用执行**KeyHook.KetyboardProc()**。
KetyboardProc()函数中发生键盘输入事件时,会比较当前进程的名称与“notepad.exe”是否相同,相同返回1,终止KetyboardProc()函数,意味着截获并删除了消息,这样键盘消息就不会传递到notepad.exe程序的消息队列。
反射DLL注入
其他注入方式多种DLL注入技术原理介绍_dll注入器-CSDN博客
反射DLL注入技术深度解析与实战 - FreeBuf网络安全行业门户
普通DLL注入通过操作目标进程内存空间,强制加载外部DLL文件。核心流程如下:
- 获取目标进程句柄:
OpenProcess - 分配内存写入DLL路径:
VirtualAllocEx+WriteProcessMemory - 创建远程线程执行加载:
CreateRemoteThread调用LoadLibrary - 清理资源:释放内存并关闭句柄
技术局限:
- 依赖
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 | SizeOfImage |
在内存中分配一块空间。
② 复制节区(Sections)
比如:
1 | .text |
把文件里的节区拷贝到内存中对应位置。
③ 处理重定位(Relocation)
如果 DLL 没加载到默认 ImageBase:就根据重定位表修正地址
④ 修复导入表(IAT)
如果你的 DLL 里有:
1 | MessageBox() |
它会:找到 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 并且注入到指定线程中,实现无文件落地攻击的技术。
ReflectiveDLL
变量定义
反射DLL被当成一段裸内存写进目标进程,然后直接跳转到 ReflectiveLoader,在未知基址执行
也就是说:代码必须能在“任意地址”运行
这叫:Position Independent Code(位置无关代码)
写:
1 | char kernel32[] = "kernel32.dll"; |
编译器会把这个字符串放到 .rdata 或 .data 段,代码里使用的是绝对地址引用
PE 文件里的绝对地址通常是:ImageBase + 偏移量
如果 ImageBase 变了,那就必须重定位。
重定位,系统会:
- 读取重定位表
- 找到所有“需要修正的地址”
- 把原地址 + (新基址 - 旧基址)算出来
如:
1
2
3 新基址 = 0x20000000
旧基址 = 0x10000000
差值 = 0x10000000
在编译时编译器不知道你未来会加载到哪里,都会按ImageBase + 偏移来生成机器码。
正常 LoadLibrary 加载顺序
1 | 系统 Loader: |
注意:重定位发生在代码执行之前
反射 DLL 的加载顺序
1 | 1️⃣ 把整个 DLL 文件当作“数据” |
类似shellcode
反射 DLL 不依赖系统 Loader,而系统 Loader 本来负责,现在不用 LoadLibrary 了,只能自己处理
首先我们在ReflectiveFunction 函数开头可以看到下面这样的声明,还记得我们在上面说的无法使用全局变量吗,这意味着我们所有的变量都必须是堆栈变量(全局变量会产生绝对地址,执行会出问题)。堆栈变量不会最终出现在编译的代码部分(需要重新定位的位置),但始终使用堆栈指针的相对偏移量进行寻址。
1 | 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' }; |
像上面这样声明我们的字符串将使编译器在运行时将这些单个字符推送到堆栈上。因此,区别在于初始化风格,定义单个字符与使用字符串文本,前者产生堆栈分配的数组,而后者产生在可执行文件的初始化数据部分中分配的数组。
获取所需系统 api
反射/手动映射早期阶段经常处于这种状态:
- IAT(导入表)可能还没修好(或者不想依赖它)
- 也不想显式调用
GetProcAddress/LoadLibrary(因为你自己要“当 loader”) - 但又必须拿到一些基础 API(VirtualAlloc/VirtualProtect/LoadLibrary/NtFlushInstructionCache…)才能继续往下做
所以就需要一条自举路径
从当前进程已加载模块列表里找到 kernel32/ntdll,再手工解析它们的 PE 导出表,找到函数地址,遇到导出转发时再递归解析
通过GPAR(GMHR(kernel32), virtualAlloc)这样的方式来获取系统 api,GMHR 是获取 dll 句柄的函数,GPAR 的功能是通过句柄获取对应导出表函数地址
1 | if ((VA = (fnVirtualAlloc)GPAR(GMHR(kernel32), virtualAlloc)) == NULL) |
在 GMHR 函数中,我们通过 PEB 来获取想要获取的函数所在 dll 的句柄。(关于 peb 的知识可以看https://xz.aliyun.com/t/13556)
1 | //----------------GET MODULE HANDLE--------------------- |
上面获取的句柄是指向内存中模块开头的指针,因此我们可以解析 dll 的 PE 标头,获取函数导出表,并且依次进行比较,并且我们的代码考虑了函数转发的情况,函数转发指的是一个 DLL 可以将其导出的函数指向另一个 DLL 的函数,通过转发,系统可以避免重复实现相同的功能。
GMHR:通过 PEB 找到某个 DLL 的模块基址(HMODULE)
这段 GMHR 在做的事情:
- 通过 GS 寄存器读出 PEB 指针
- 从
PEB->Ldr拿到 Loader 数据结构 - 遍历
InMemoryOrderModuleList链表 - 比较每个模块的
FullDllName/BaseDllName,找到名字匹配的模块 - 返回该模块的句柄(本质上就是模块在内存中的基址)
关键点解释:
- 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 | /*------------------获取函数地址-------------------*/ |
好的,到现在位置我们就可以获取到我们所需要的系统 api 了
申请 dll 所需要的内存空间
虽然我们的 dll pe 已经在内存里面了,但是我们还需要更大的一个内存空间对其加载,完成映射节,解析导入表,重定位表等等操作,因此我们需要一片更大的内存空间,我们直接在上面获取系统 api 的步骤中获取 VirtualAlloc 即可,而所需要的内存空间大小是 pe 文件格式里面 IMAGE_OPTIONAL_HEADER 的SizeOfImage 确定
dll pe:这个 DLL 文件的 PE 格式结构数据(原始文件内容)
1 | if ((pebase = (PBYTE)VA(NULL, pImgOptHdr->SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)) == NULL) |
复制节
我们接下来要把节映射过去,由于节在内存中应该是虚拟地址,所以我们不能一股脑复制过去,要借助IMAGE_SECTION_HEADER 里面的VirtualAddress 字段帮助我们复制
为什么不能一股脑复制整个 DLL?
因为现在手里 dllBaseAddress 指向的是 原始 PE 文件字节(文件布局),文件布局的特点是:
- 节区在文件里按
PointerToRawData排列(文件偏移) - 节区大小按
SizeOfRawData(文件对齐 FileAlignment) - 节区位置和间隔不等同于运行时的虚拟地址布局
而运行时真正执行时,CPU/代码访问的是:
- RVA(VirtualAddress) 为基准的布局(内存对齐 SectionAlignment)
- 例如
.text通常从 RVA 0x1000 开始,而不是文件偏移 0x400
所以必须按节映射,把:文件偏移 → 拷到 → 内存虚拟地址
1 | // 为节头(section headers)数组分配内存 |
修复导入表 IAT
一旦各个节被加载到正确的虚拟地址中,所有的相对虚拟地址(RVA)就开始有意义了。因此,在这里我们可以开始修复导入目录(Import Directory):遍历我们反射 DLL 需要操作的所有 DLL 列表,导入它们,并根据我们在内存中获得的位置调整每个函数的 RVA。基本上将所有的 RVA 转换为 VA(虚拟地址),即 VA = ImageBase + RVA。
1 | for (size_t i = 0; i < pImgOptHdr->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size; i += sizeof(IMAGE_IMPORT_DESCRIPTOR)) { |
修复重定位表
现在,导入地址表也已修复,这意味着如果DLL在该进程的内存中执行,它将知道在哪里找到所需的函数。现在是应用基址重定位的时候了,我们可以简要说明一下重定位的工作原理:当程序被编译时,编译器假定一个特定的基址作为可执行文件的基址。然后基于这个基址计算并嵌入了各种地址。然而,可执行文件加载时不太可能正好加载到这个基址。相反,它可能加载到一个不同的地址,这使得所有这些嵌入的地址无效。为了解决这个加载问题,一个包含所有这些需要调整的嵌入地址的列表被存储在PE文件的一个专门表中,称为重定位表(Relocation Table)。这个表位于.reloc节的一个数据目录中。
1 | /*--------------修复重定位--------------*/ |
为每个节分配正确的内存属性
我们根据IMAGE_SECTION_HEADER 的Characteristics 字段确定每个节的属性然后为其分配即可
反射 DLL 注入本质上是在手动实现 Windows PE Loader 的工作,而 PE Loader 在加载 DLL 时本来就会根据节属性设置精确的页保护,否则程序无法正常运行。
如果把所有节都设为:PAGE_EXECUTE_READWRITE虽然能跑,但非常可疑
1 | for (int i = 0; i < ImgFileHdr.NumberOfSections; i++) { |
调用 dll 入口点
最后我们刷新指令缓存,使得我们先前的工作生效,然后返回入口点地址就可以了,然后就会完成C运行库的初始化,执行一系列安全检查并调用dllmain。
1 | FIC((HANDLE)-1, NULL, 0x00); |
ReflectiveInject
在 inject 里面要做的事情主要有一下几步:
下载/读取我们的 DLL 字节
查找 ReflectiveFunction 的 RAW 地址
在DLL文件中找到ReflectiveFunction的原始地址。这通常需要解析DLL的PE结构以定位目标函数的地址。
反射注入的关键在于:把 DLL 原始字节塞进了远程进程的一块内存里,但系统并不知道那是个模块,也不会解析导入表/重定位/节权限等。
所以必须先定位一个引导入口函数(常叫 ReflectiveLoader / ReflectiveFunction):
- 这个函数的作用不是业务逻辑,而是在目标进程里把自己加载成一个真正可运行的模块。
- 因为DLL 还没被正常加载,所以没法靠常规方式(比如 GetProcAddress(模块句柄, 函数名))去找它。
- 因此只能从 DLL 文件的 PE 结构里定位它在文件中的位置(RAW/文件偏移),然后换算成将来写进远程内存后的对应地址。
可以把它想象成:
把一本书(DLL字节)搬进别人家(远程进程),但书还没拆封(没加载)。得先找到拆封说明书的那一页(ReflectiveLoader),然后让别人从那一页开始读,才能把书摊开用。
在远程进程中分配内存:在目标远程进程中分配足够的内存,以容纳即将写入的DLL字节。
在远程内存位置写入 RAW 字节:将下载或读取到的DLL字节写入分配好的远程内存中。
创建一个将运行“ReflectiveLoader”函数的远程线程:在远程进程中创建一个线程,以运行ReflectiveLoader函数,这样DLL就可以在目标进程中进行自我加载。
在目标进程里,必须有一段代码开始执行,去完成自加载那一整套动作。ReflectiveLoader 就相当于一个微型PE加载器,它运行后通常会:
- 在目标进程里为“最终映像”再找/准备一块合适的内存
- 把各节按内存布局重新安放
- 修复重定位 + 解析导入
- 设置节权限
- 调用入口初始化(DLLMain / TLS 等)
- 然后返回/自清理(不同实现不同)













































