本文根据 tmp.0ut 第五期中 febnug 的文章编译整理,非逐字翻译;文中观点均属于原作者。
原文:Control Flow That Isn’t There: State Without State
文章内容来自 tmpout.sh,本译文依原站 CC BY-NC-SA 4.0 许可作为改编内容发布。如有侵权请通知,我会下架文章。 全文字符数:3337
摘要
大多数 shellcode 总要把状态存在某处:寄存器、栈、内存——总能指出某个地方。本文这个构造不存状态:状态由标志位承载,控制流在运行时才被选择;从时序中导出的一个比特决定执行如何进入一段共享字节区域。二进制看起来是固定的——确实固定。同样的字节,不同的执行;静态分析恢复出的路径,可能永远不会真正运行。
1. 引言
逆向工程通常从一个简单假设出发:二进制描述的是一个程序。指令是静态的、控制流是固定的、状态是可见的。但这个假设并不总是成立。如果控制流依赖从不被存储的标志位,而且执行可以从同一字节序列的不同偏移开始,那么你恢复出的结构只是一种猜测——你看到的未必是真正运行的。
2. 概览
整个构造由三个小部件组成:
- 存在标志位里的一个比特;
- 从时序导出的一个比特;
- 在运行时选择的一次控制转移。
伪代码流程:
t = rdtsc()
bit = (t.low ^ t.high) & 1
if bit:
CF = 1
else:
CF = 0
if CF:
jump overlap_shift
else:
jump overlap
3. 用标志位存状态
进位标志(CF)被用作单比特状态容器,不需要内存写入或显式状态跟踪:
if entropy_bit:
stc
else:
clc
jc entry_B
状态是隐式的:它不被保存也不被恢复,只作为执行过程的副作用存在,并直接影响控制流。没有任何变量持有它,它只活在标志位里。
4. 用时序当熵源
从 rdtsc 提取一个熵比特:
rdtsc
xor eax, edx
test al, 1
这个值随执行而变化,并且对时序条件敏感——包括调试和插桩造成的时序偏移。熵只有一比特,但足以影响控制流。
5. 运行时选择控制
这个构造不重写指令。控制流由“执行如何进入共享字节区域”决定:时序比特被映射进 CF,用来在两个入口之间选择:
jc entry_B
entry_A:
jmp overlap
entry_B:
jmp overlap_shift
两个入口都指向同一块连续字节区域,只是偏移不同。由于 x86 解码对对齐敏感,从不同偏移开始执行会产生不同的指令流——底层字节完全相同,指令并没有变,变的是对它们的解释。
6. 重叠执行
重叠区域由两条相邻的跳转编码构成:
overlap: db 0xeb, <rel real>
overlap_shift: db 0xeb, <rel alt>
这些字节在编译期固定,运行时不修改。但从不同入口进入时,同一区域会被解码成不同指令序列,从而得到不同的控制流路径。这不是“把多条代码路径分别存起来”,而是一段字节序列被多种方式解释。
7. 实现
执行先从 rdtsc 导出单比特并映射进 CF:
rdtsc
xor eax, edx
test al, 1
比特为 1 时用 stc 置位 CF,否则清除;随后用 jc 选择两个入口之一:
jc entry_B
entry_A:
jmp overlap
entry_B:
jmp overlap_shift
重叠区域就是两条相邻跳转编码:
overlap: db 0xeb, <rel real>
overlap_shift: db 0xeb, <rel alt>
这些字节运行时不修改;只是执行可能从不同偏移开始,从而被解码成不同路径。分歧不是由单独指令编码的,而是从“如何进入共享字节序列”中涌现的。载荷数据内联存放,用短 call/pop 序列访问:
jmp short data
ret:
pop rsi
这避免了绝对寻址,让代码位置无关。整个执行不需要可写内存,也不重写任何指令;代码全程保持静态,变化的只是进入重叠区域的入口点。
8. 作为 shellcode
这个构造可以表达成位置无关的 shellcode。实现避免绝对寻址,只用相对控制转移;内联数据用短 call/pop 访问。为支持注入场景,可以用字节宽立即数和短格式控制转移写成不含 NUL 字节的版本。它保持同样的性质:没有显式状态存储、控制流依赖运行时、存在重叠执行区域。这说明该技术不依赖 ELF 二进制,可以直接用于注入代码。
9. 意义
静态分析假设“字节到指令”的映射固定。当执行取决于入口偏移和标志位这类隐式状态时,这个假设就失效了。反汇编器通常会恢复出一个合法的控制流图,但该图可能不对应任何一次真实执行。这种差异不是混淆层或加密造成的,而是执行模型本身的根本性质。
10. 行为
多次运行会得到不同输出:
hello
holla
hello
hello
具体序列取决于时序与标志状态。调试时,时序偏移会让执行偏向某条路径,使行为依赖运行环境。
11. 结论
这里给出了一个最小构造:状态由标志位承载,控制流在运行时选择,执行依赖进入重叠字节区域的入口。不需要显式状态存储,不需要代码变异。代码没有变,变的只是穿过它的路径。
12. 概念验证
下面是该想法的最小实现。它位置无关,既可以作为小型 ELF,也可以作为裸 shellcode。执行从 rdtsc 开始,提取一个比特并用 stc/clc 放进 CF——哪里都没有存储任何东西,标志就是唯一的状态。之后执行被推进两个入口之一,都落在同一字节区域内、只是偏移不同。重叠区就是两条相邻跳转:
overlap: jmp real
overlap_shift: jmp alt
运行时不改任何字节,差异完全来自执行从哪里开始。同样的字节,不同的解码。payload 内联存放,用短 call/pop 恢复,没有绝对引用和重定位。
程序运行时打印 hello\n 或 holla\n 之一。从静态布局看不出选择依据——静态分析只给你一条路径,而 CPU 可能走另一条。
global _start
section .text
_start:
rdtsc
xor eax, edx
test al, 1
jz noflip
stc
jmp decide
noflip:
clc
decide:
jc entry_B
entry_A:
jmp overlap
entry_B:
jmp overlap_shift
; --- OVERLAP ---
overlap:
db 0xeb, real - overlap - 2
overlap_shift:
db 0xeb, alt - overlap_shift - 2
; --- PAYLOAD ---
alt:
jmp short alt_data
alt_pop:
pop rsi
jmp write
alt_data:
call alt_pop
db 'h','o','l','l','a',0x0a
real:
jmp short real_data
real_pop:
pop rsi
jmp write
real_data:
call real_pop
db 'h','e','l','l','o',0x0a
write:
push 1
pop rdi ; stdout
xor eax, eax
mov al, 1
xor edx, edx
mov dl, 6
syscall
xor eax, eax
mov al, 60
xor edi, edi
syscall
这个版本无 NUL 字节且完全位置无关,适合作为 shellcode:
unsigned char shellcode[] =
"\x0f\x31\x31\xd0\xa8\x01\x74\x03\xf9\xeb\x01\xf8\x72\x02"
"\xeb\x02\xeb\x02\xeb\x12\xeb\x00\xeb\x03\x5e\xeb\x1b\xe8"
"\xf8\xff\xff\xff\x68\x6f\x6c\x6c\x61\x0a\xeb\x03\x5e\xeb"
"\x0b\xe8\xf8\xff\xff\xff\x68\x65\x6c\x6c\x6f\x0a\x6a\x01"
"\x5f\x31\xc0\xb0\x01\x31\xd2\xb2\x06\x0f\x05\x31\xc0\xb0"
"\x3c\x31\xff\x0f\x05";
13. 参考
- Covert Computation: Hiding Code in Code,Computers & Security, 2014(论文)
- Christopher Eagle,The IDA Pro Book: The Unofficial Guide to the World’s Most Popular Disassembler, 2nd Edition, 2011
- Skape & Skywing,Understanding Windows Shellcode,Uninformed Journal, 2007
- A Taxonomy of Self-Modifying Code for Obfuscation,Computers & Security, 2011(论文)