机房门被推开的时候,陈默的手已经按在了电源线上。
不是风。
是一个人的轮廓,站在应急灯的光锥边缘。头发散乱,外套上沾着雨渍,手里握着一个黑色的 U 盘。
程思语。
她没出声,径直走进来,把 U 盘放在 ThinkPad 旁边。然后坐在机架底座上,脱掉湿透了的外套。
「你回来了。」陈默说。这三个字比他预想的轻。
「快了半截。」她的声音哑得厉害,「他们比我想的快。老宅外面停了两辆没牌照的车——我爸的人已经到了合肥。」
「证书呢?」
程思语没回答。她把 U 盘插进 ThinkPad。终端自动挂载,显示一个文件:
```
shenma_codesign.p12
```
「拿到了。」她说,「但我妈书房的窗户碎了。他们知道我进过老宅。」
陈默盯着那个文件。代码签名证书。有了它,就可以重新编译神码 AI 的 C 扩展,植入真正的 `raise` 逻辑,替换那些被编译进二进制层的 `except: pass`。
「他们什么时候到科学岛?」
「如果从老宅直接过来——」程思语看了一眼手机,「四十分钟。如果他们在等指令——时间未知。」
陈默把 U 盘里的证书复制到本地。然后打开终端。
「四十分钟。够写一个 C 扩展修复器。」
「你之前说 Python 碰不到 C 扩展内部。」
「那就不从 Python 碰。」
陈默敲下了第一行代码——不是 Python,是终端命令:
```bash
objdump -d /lib/shenma/core/_settle_core.cpython-311-x86_64-linux-gnu.so \
| grep -A 20 'PyErr_SetString'
```
输出像瀑布一样滚过屏幕。几十个 `PyErr_SetString` 调用点散布在反汇编代码中——这些是 C 扩展里真正的错误处理路径。但陈默要找的不是这些。他找的是那些**没有** `PyErr_SetString` 的 try-catch 块。
「C 扩展的异常处理是靠 `PyErr_SetString` 和 `PyErr_SetObject` 传播到 Python 层的。」他说,「如果程泰来在 C 层面把 `PyErr_SetString` 调用删了——」
「异常就不会传播。C 层的错误被吞了,Python 层永远不知道。」程思语接上。
「对。而且 C 扩展里的 `except: pass` 不是 Python 字节码——它是 C 语言的 `setjmp` / `longjmp` 结构,或者是 C++ 的异常处理表。」
陈默打开 `readelf`:
```bash
readelf -S /lib/shenma/core/_settle_core.cpython-311-x86_64-linux-gnu.so \
| grep -E '(eh_frame|gcc_except_table|ARM.extab)'
```
输出:
```
.eh_frame_hdr PROGBITS 0000000000002a40 00002a40
.eh_frame PROGBITS 0000000000002b00 00002b00
```
「有异常处理表。」陈默说,「说明编译的时候带了异常处理支持。但这些表——可能已经被篡改了。」
程思语凑过来:「你能读异常处理表?」
「不能直接读。但可以用 `ctypes` 加载 `.so`,然后从二进制层面分析它的异常处理覆盖率。」
陈默新建了一个 Python 文件——`binary_raiser.py`。他敲下的速度比之前任何一章都快:
```python
import ctypes
import sys
# 用 ctypes.CDLL 加载 C 扩展,绕过 Python 的 import 链
lib = ctypes.CDLL('/lib/shenma/core/_settle_core.cpython-311-x86_64-linux-gnu.so')
# 通过 dlsym 获取 C 扩展的 PyInit 函数指针
# 标准的 C 扩展入口点
init_func_name = b'PyInit__settle_core'
try:
init_ptr = ctypes.pythonapi.PyCapsule_Import(
ctypes.c_char_p(init_func_name), 0
)
except Exception as e:
print(f"PyCapsule_Import 失败: {e}")
# 回退:手动调用 dlsym
libc = ctypes.CDLL(None) # 加载全局符号
dlsym = libc.dlsym
dlsym.restype = ctypes.c_void_p
init_ptr = dlsym(
lib._handle,
init_func_name
)
print(f"dlsym 获取 PyInit 指针: {hex(init_ptr or 0)}")
```
程思语看着代码皱眉:「你用 `ctypes.CDLL` 加载 `.so`,然后从 `dlsym` 获取 `PyInit` 函数——你这是从 Python 里手动调用 C 扩展的入口?」
「对。不走 `import` 链,不走 Python 的模块系统。直接操作共享库。」
陈默继续敲。下一段代码是核心——他要从二进制层面分析这个 C 扩展的所有导出函数,检查每个函数里是否有合法的异常传播路径:
```python
# 遍历 C 扩展的符号表,找到所有 Python 可调用的函数
# 用 ctypes 获取函数指针后,通过内存分析检查其行为
# 方法:查找 .dynsym 段中的函数符号
import struct
def read_elf_symbols(so_path):
"""用纯 Python + ctypes 解析 ELF 符号表"""
with open(so_path, 'rb') as f:
elf = f.read()
# ELF header
if elf[:4] != b'\x7fELF':
raise ValueError("不是 ELF 文件")
# 64-bit ELF
e_shoff = struct.unpack_from('<Q', elf, 0x28)[0] # Section header offset
e_shentsize = struct.unpack_from('<H', elf, 0x3A)[0]
e_shnum = struct.unpack_from('<H', elf, 0x3C)[0]
# 遍历 section headers 找 .dynsym
for i in range(e_shnum):
sh_off = e_shoff + i * e_shentsize
sh_name_idx = struct.unpack_from('<I', elf, sh_off)[0]
sh_type = struct.unpack_from('<I', elf, sh_off + 4)[0]
sh_addr = struct.unpack_from('<Q', elf, sh_off + 0x10)[0]
sh_offset = struct.unpack_from('<Q', elf, sh_off + 0x18)[0]
sh_size = struct.unpack_from('<Q', elf, sh_off + 0x20)[0]
if sh_type == 11: # SHT_DYNSYM
return sh_offset, sh_size, sh_addr
return None, None, None
sym_offset, sym_size, sym_addr = read_elf_symbols(
'/lib/shenma/core/_settle_core.cpython-311-x86_64-linux-gnu.so'
)
print(f"动态符号表偏移: 0x{sym_offset:x}, 大小: {sym_size}")
```
「你在手写 ELF 解析器?」程思语的声音里带着难以置信。
「不需要 libelf。Python 的 `struct` 读 ELF 足够了。我们现在在二进制的层面——标准库的 `import` 是公路,`ctypes.CDLL` 是越野车,而 `struct.unpack` 读 ELF 是趴在泥里用手挖。」
他敲完这段,终端输出:
```
动态符号表偏移: 0x1a800, 大小: 0x3000
```
「找到了。现在遍历符号,标记所有导出函数。」陈默继续:
```python
ENTRY_SIZE = 0x18 # 64-bit ELF 符号表条目大小
with open('/lib/shenma/core/_settle_core.cpython-311-x86_64-linux-gnu.so', 'rb') as f:
f.seek(sym_offset)
symbols = []
for _ in range(sym_size // ENTRY_SIZE):
raw = f.read(ENTRY_SIZE)
st_name = struct.unpack_from('<I', raw, 0)[0]
st_info = raw[4]
st_other = raw[5]
st_shndx = struct.unpack_from('<H', raw, 6)[0]
st_value = struct.unpack_from('<Q', raw, 8)[0]
st_size = struct.unpack_from('<Q', raw, 16)[0]
# STT_FUNC (2) 且 st_value > 0 (有地址)
if st_info & 0xf == 2 and st_value > 0:
symbols.append({
'addr': st_value,
'size': st_size,
'name_offset': st_name,
})
print(f"找到 {len(symbols)} 个函数符号")
```
终端显示:
```
找到 87 个函数符号
```
「八十七个 C 函数。」陈默说,「其中每一个都可能包含 `except: pass` 的二进制等价物——C 语言的 setjmp 异常处理。」
程思语指着一行:「你能从二进制分析里面有没有异常处理?」
「不能精确分析。」陈默说,「但可以做一个启发式检测——检查函数的汇编代码里有没有 `PyErr_SetString` 或 `PyErr_SetObject` 调用。如果一个函数处理了错误条件但没有调用任何 `PyErr_*` 函数——它就是可疑的。」
他敲下新代码——这次结合了 `objdump` 的输出和 `ctypes`:
```python
import subprocess
def check_pyerr_in_function(so_path, func_addr, func_size):
"""用 objdump 反汇编函数范围,检查 PyErr 调用"""
result = subprocess.run(
['objdump', '-d', '--start-address=' + hex(func_addr),
'--stop-address=' + hex(func_addr + func_size), so_path],
capture_output=True, text=True
)
disassembly = result.stdout
# 检查 PyErr 调用
has_pyerr = 'PyErr_SetString' in disassembly or \
'PyErr_SetObject' in disassembly or \
'PyErr_Format' in disassembly
return has_pyerr, disassembly
# 批量扫描所有大函数 (>100 字节的可能性是真正的逻辑函数)
suspicious = []
for sym in symbols:
if sym['size'] > 100:
has_pyerr, _ = check_pyerr_in_function(
'/lib/shenma/core/_settle_core.cpython-311-x86_64-linux-gnu.so',
sym['addr'], sym['size']
)
if not has_pyerr:
suspicious.append(sym)
print(f"可疑函数(无 PyErr 调用): {len(suspicious)}")
```
终端输出:
```
可疑函数(无 PyErr 调用):6
```
程思语的声音绷紧了:「六个函数完全没有错误传播?」
「可能只是不产生错误——也可能是程泰来把 `PyErr_SetString` 调用全部删了,只剩下返回 NULL。」
陈默从 U 盘里提取出原始的、未署名的神码 AI C 扩展源码——壳公司内网的备份,在程泰来下毒之前的版本:
```bash
diff <(objdump -d original_settle.o) <(objdump -d _settle_core.so)
```
输出几乎占满了整个终端。陈默目光扫过第一屏——然后停在了一行上:
```
- call PyErr_SetString
+ nop
+ nop
+ nop
+ nop
+ nop
```
五条 NOP 指令,覆盖了一个原本的 `PyErr_SetString` 调用。
「证据。」程思语的声音冷冷的,「不是没写异常处理——是写好了,然后在编译之后用二进制补丁把调用抹成了 NOP。」
「他改的不是源码。是编译好的 `.so`。」陈默说,「在 ELF 层面,直接写二进制补丁。」
程思语沉默了几秒。
「那如果你能把这些 NOP 修复回来——」
「把那五个 NOP 恢复成 `call PyErr_SetString`。」
陈默的手指悬在键盘上方。他在想一个问题:怎么在不知道原始调用地址的情况下,重建 `call` 指令?
`call` 指令在 x86-64 上是 `E8 <rel32>`——五字节。相对偏移需要知道目标地址。
「我恢复不了原来的 `call`。」陈默说,「因为我不知道 `PyErr_SetString` 在这个 `.so` 文件里的 PLT 偏移。」
「但你可以做别的——」
「对。我可以在这个位置插入一个 `ud2` 指令,让程序在到达这段路径时立刻崩溃。有 crash 就有 traceback——总比静默失败强。」
陈默的手指飞起来:
```python
import mmap
import os
def patch_nop_to_ud2(so_path, nop_offset, nop_count):
"""将 NOP (0x90) 序列替换为 UD2 (0x0F 0x0B) 触发显式崩溃"""
with open(so_path, 'r+b') as f:
with mmap.mmap(f.fileno(), 0) as mm:
# 定位到 NOP 序列
nop_bytes = mm[nop_offset:nop_offset + nop_count]
if nop_bytes != b'\x90' * nop_count:
print(f"偏移 0x{nop_offset:x} 不是纯 NOP 序列")
return False
# UD2 指令:0F 0B
ud2 = b'\x0f\x0b'
# 剩余字节用 INT3 (0xCC) 填充
patch = ud2 + b'\xcc' * (nop_count - 2)
mm[nop_offset:nop_offset + nop_count] = patch
print(f"已修补 0x{nop_offset:x}: NOP→UD2+INT3")
return True
# 从 objdump 找出所有被替换的 PyErr 位置
result = subprocess.run(
['objdump', '-d', '/lib/shenma/core/_settle_core.cpython-311-x86_64-linux-gnu.so'],
capture_output=True, text=True
)
for line in result.stdout.split('\n'):
if 'nop' in line and 'call' in result.stdout.split(line)[0][-100:]:
# 提取地址
pass # 简化:实际需要更精细的解析
```
「太慢了。」陈默删掉那段,换了一个更直接的方法——在运行时用 `ctypes` 注入一个监控层:
```python
import ctypes.util
# 获取 libpthread 中的 __libc_dlopen_mode
# Python 的 ctypes.CDLL 底层就是调用 dlopen
# 我们劫持它
_libc = ctypes.CDLL(None)
_real_dlopen = _libc.dlopen
_real_dlopen.restype = ctypes.c_void_p
_real_dlopen.argtypes = [ctypes.c_char_p, ctypes.c_int]
@ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int)
def _hooked_dlopen(filename, flags):
"""劫持 dlopen:在加载共享库之前做安全检查"""
if filename and b'shenma' in filename:
print(f"[RaiseGuard] dlopen 拦截: {filename.decode()}")
# 调用原始的 dlopen
handle = _real_dlopen(filename, flags)
if handle and filename and b'shenma' in filename:
print(f"[RaiseGuard] 已加载: {filename.decode()}")
# TODO: 在此处注入异常处理修补逻辑
return handle
# 将 GLIBC 的符号表里的 dlopen 替换为我们的钩子
# 注意:ctypes 默认使用 RTLD_LOCAL,hook 需要 LD_PRELOAD 级别
```
陈默敲到 `TODO` 那里停了。他盯着那行注释,手指停在键盘上方。
程思语看出来了:「有困难?」
「Python 的 `ctypes.CDLL` 使用 `dlopen` 的默认行为——RTLD_LOCAL。就算我 hook 了 `_libc.dlopen`,Python 内部加载 C 扩展用的是自己的 `_Py_dlopen` 封装,走的是不同的调用链。」
「那条调用链——」
「在 CPython 的 `Modules/_ctypes/_ctypes.c` 里。它不调用 `dlopen` 本身——它调用 `PyCapsule_Import`,然后走的内部 `_ctypes` 扩展的加载路径。」
陈默打开了 CPython 的源码——从本地检出的一份 3.11 源码里找到了关键行:
```python
# Python/importdl.c
dl_funcptr _PyImport_FindSharedFuncptr(
const char *prefix,
const char *shortname,
const char *pathname,
FILE *fp)
{
// ...
handle = _Py_dlopen(pathname, dlopenflags);
// ...
}
```
「`_Py_dlopen`。」程思语念出声,「不是一个标准 C 库函数——是 CPython 内部对 `dlopen` 的封装。」
「对。它在 `Python/dynload_shlib.c` 里定义。即使我 hook 了 C 库的 `dlopen`,CPython 内部用的是自己的符号。」
程思语沉默了几秒。然后她说:「那你怎么办?」
陈默没有回答。他打开了一个新的 Python 文件——文件名叫做 `shadow_loader.py`:
```python
import sys
import ctypes
# 核心思路:不劫持 dlopen,而是劫持 Python 内部用于
# 加载 C 扩展的 _bootstrap_external 模块
#
# 在 Python 3.11 中,ExtensionFileLoader.create_module
# 最终调用 _imp.create_dynamic,这是一个内置函数
#
# 但我们有一个更底层的路径:
# 通过 ctypes 直接访问 CPython 的 C API
pythonapi = ctypes.pythonapi
# PyImport_AppendInittab — 在解释器初始化前注册内置模块
# 但我们不能重启解释器...
#
# 另一个路径:sys.setdlopenflags
# 控制 dlopen 的 flag,但没有我们需要的拦截能力
# 真正的方案:
# 利用 LD_PRELOAD + ctypes 构建一个透明的 C 扩展监控层
# 1. 生成一个小的 C 语言共享库,使用 LD_PRELOAD
# 2. 这个库劫持 _Py_dlopen
# 3. 在加载 C 扩展后,扫描其异常处理表
```
陈默停下手指。
「我需要写一个 C 文件。」他说。
程思语看着他:「你会 C?」
「不会写好看的 C。但会写够用的 C。」
他打开一个终端窗口,敲下:
```c
/* shadow_dlopen.c — LD_PRELOAD 劫持 CPython 的 _Py_dlopen */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <string.h>
#include <stdio.h>
/* 原始的 dlopen 函数指针 */
static void* (*real_dlopen)(const char*, int) = NULL;
/* 在库被加载后的回调 */
void _shadow_on_load(const char* path, void* handle) {
fprintf(stderr, "[ShadowDL] 加载: %s\n", path);
/* TODO: 扫描异常处理表,修补 NOP */
}
/* 劫持 dlopen */
void* dlopen(const char* filename, int flags) {
if (!real_dlopen) {
real_dlopen = dlsym(RTLD_NEXT, "dlopen");
}
void* handle = real_dlopen(filename, flags);
if (handle && filename && strstr(filename, "shenma")) {
_shadow_on_load(filename, handle);
}
return handle;
}
```
「LD_PRELOAD 劫持 `dlopen`。」程思语说,「这个钩子在 C 层面,在 CPython 调用 `_Py_dlopen` 之前就生效——因为 `_Py_dlopen` 最终也调 `dlopen`。」
「对。Python 层面的 import hook 拦不住 C 扩展的加载。但 C 层面的 LD_PRELOAD 可以——因为它是最底层的函数替换。」
陈默编译了 shadow_dlopen.c:
```bash
gcc -shared -fPIC -o shadow_dlopen.so shadow_dlopen.c -ldl
```
然后他写了一段 Python 测试——在同一个进程里利用 `ctypes` 注入 LD_PRELOAD 级别的钩子:
```python
# 利用 ctypes 加载 shadow_dlopen.so
# 这会替换进程内所有的 dlopen 调用
import ctypes
# 使用 RTLD_GLOBAL 使 shadow 中的符号全局可见
shadow = ctypes.CDLL('./shadow_dlopen.so', mode=ctypes.RTLD_GLOBAL)
# 现在任何 dlopen 调用 — 包括 CPython 内部的 —
# 都会先经过我们的 shadow
# 测试:用 import 加载一个 C 扩展
import importlib
spec = importlib.util.find_spec('_settle_core')
print(f"规范: {spec}")
```
终端输出:
```
[ShadowDL] 加载: /lib/shenma/core/_settle_core.cpython-311-x86_64-linux-gnu.so
```
「它工作了。」程思语说。
「它工作了。」陈默重复。他的声音里没有兴奋。只有确认——就像验证了一个数学定理。
但他下一步没有继续写 C 扩展的修补逻辑。他停下来,看了一眼屏幕右上角的电量:61%。
然后他做了一个决定。
「这个 LD_PRELOAD + ctypes 的路径——可以做更深的事。」他打开了一个新的终端,「不只是检测。不只是修补 NOP。而是——」
「——重新编译。」程思语替他说完。
陈默摇头:「没有完整的源码,重新编译不可能。但 `dlopen` 之后,在 `PyInit_` 函数执行之前——我可以在内存里修补 `.so` 的 `.text` 段。」
「运行时二进制修补?」
「运行时。内存中。用 `mmap` + `ctypes` 写可执行内存。」
陈默写了一段危险的代码——直接修改已加载共享库的内存:
```python
import ctypes
import sys
# 获取 _settle_core 模块对象
settle = sys.modules.get('_settle_core')
if not settle:
print("模块未加载")
sys.exit(1)
# 通过 ctypes 获取模块的 __spec__.origin 对应的 .so 文件
# 然后找到它在内存中的加载基址
#
# 方法:从 /proc/self/maps 读取
with open('/proc/self/maps', 'r') as f:
for line in f:
if '_settle_core' in line and 'r-xp' in line:
# 可执行段
parts = line.split()
addr_range = parts[0]
start, end = [int(x, 16) for x in addr_range.split('-')]
size = end - start
print(f"代码段: 0x{start:x} - 0x{end:x} ({size} 字节)")
# 使用 ctypes 内存操作 + mmap 修改保护
libc = ctypes.CDLL(None)
mprotect = libc.mprotect
mprotect.restype = ctypes.c_int
mprotect.argtypes = [
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_int
]
PROT_READ = 0x1
PROT_WRITE = 0x2
PROT_EXEC = 0x4
# 将代码段改为可写
page_start = start & ~(0x1000 - 1) # 页对齐
ret = mprotect(
ctypes.c_void_p(page_start),
size + (start - page_start),
PROT_READ | PROT_WRITE | PROT_EXEC
)
print(f"mprotect: {ret}")
if ret == 0:
# 成功!现在可以写内存了
# 定位到 NOP 序列的位置
nop_offset = start + 0x2a3f # 从 objdump 得到的偏移
buf = (ctypes.c_uint8 * 5).from_address(nop_offset)
old = bytes(buf)
print(f"原始字节: {old.hex()}")
# 写入 UD2 (0F 0B) + INT3 (CC CC CC)
buf[0] = 0x0F
buf[1] = 0x0B # UD2
buf[2] = 0xCC # INT3
buf[3] = 0xCC # INT3
buf[4] = 0xCC # INT3
new = bytes(buf)
print(f"修补后字节: {new.hex()}")
# 恢复保护
mprotect(
ctypes.c_void_p(page_start),
size + (start - page_start),
PROT_READ | PROT_EXEC
)
```
程思语看着这段代码,脸上没有表情。但她放在膝盖上的手指微微颤了一下。
「你在做的事——」她说,「是从 Python 里,通过 `/proc/self/maps` 找到 C 扩展的内存地址,然后调用 `mprotect` 把代码段改成可写,然后用 `ctypes` 直接往内存里写机器码。」
「是。」
「这已经超出 Python 的范畴了。这是在用 Python 写二进制注入工具。」
「Python 是胶水语言。」陈默说,「它的胶水能粘到机器码层面。」
终端显示:
```
代码段: 0x7f2a3c000000 - 0x7f2a3c004000 (16384 字节)
mprotect: 0
原始字节: 9090909090
修补后字节: 0f0bcccccc
```
五个 NOP,变成了一个 UD2 加三个 INT3。如果任何代码路径执行到这个位置——程序会立刻崩溃,操作系统会生成 core dump 和 traceback。
静默的 `except: pass`,变成了世界看得见的 crash。
「一个。」陈默说。
「还有十二个。」程思语说。
陈默没有停下来。他写了一个循环——先反汇编出 `.so` 里所有被替换的 `PyErr_SetString` 位置,然后全部修补成 UD2:
```python
def patch_all_nop_sites(so_path):
"""找到并修补所有疑似被 NOP 覆盖的 PyErr 调用点"""
result = subprocess.run(
['objdump', '-d', so_path],
capture_output=True, text=True
)
patches = []
lines = result.stdout.split('\n')
for i, line in enumerate(lines):
# 查找连续的 NOP 指令序列 (至少 3 条)
if 'nop' in line:
# 确认这是否出现在一个可能调用 PyErr 的位置
# 特征:前面有 test/jne 或 cmp/jne 的分支
context = '\n'.join(lines[max(0, i-5):i])
if any(x in context for x in ['test', 'cmp', 'jne', 'je', 'jz', 'jnz']):
addr_str = line.split(':')[0].strip()
try:
addr = int(addr_str, 16)
patches.append(addr)
except ValueError:
continue
# 去重:只保留每组 NOP 序列的第一个地址
# (简化版本)
return patches
nop_sites = patch_all_nop_sites(
'/lib/shenma/core/_settle_core.cpython-311-x86_64-linux-gnu.so'
)
print(f"找到 {len(nop_sites)} 个可疑 NOP 位置")
```
终端输出:
```
找到 5 个可疑 NOP 位置
```
「五个。」程思语说。
「五个 NOP 补丁点,覆盖一个 C 扩展。」陈默说,「还有二十三个 C 扩展要处理。」
他打开了下一个 `.so` 文件——`_image_processor.cpython-311-x86_64-linux-gnu.so`。医疗影像处理的 C 扩展。就是那个在程思语的报告中,让 CT 报告标注被静默吞掉的元凶。
陈默的手指停了。
不是因为代码。
是因为他听到了什么。
远处,科学岛主楼的铁门——又响了。这一次不是猫。是金属撞击水泥的声音。然后是说话声——模糊的、隔着几堵墙的人声。
程思语站起来,把应急灯关掉。
机房陷入彻底的黑暗,只有 ThinkPad 的屏幕亮着,在两个人的脸上投下冷白色的光。
「来不及一个一个修了。」陈默的声音在黑暗中很低,「十三个不可达的 C 路径,二十三个 C 扩展。一个一个写 UD2——时间不够。」
「那你打算怎么办?」
陈默打开了一个新文件——文件名:`injector.py`。
「不一个一个修。写一个通用的——在 `dlopen` 之后、`PyInit` 之前,自动扫描代码段里的 NOP 序列并替换为 UD2。」
「你要写一个二进制的运行时自动修复器?」
陈默已经开始敲了:
```python
def auto_patch_c_extension(handle, so_path):
"""在 C 扩展初始化前,自动查找并修补 NOP 异常处理"""
# 通过 dlpi_addr 获取基址
from ctypes import pythonapi, c_void_p, c_char_p, c_int, POINTER
# 使用 dl_iterate_phdr 遍历共享库
# 或者从 /proc/self/maps 里找
with open('/proc/self/maps', 'r') as f:
for line in f:
if so_path.split('/')[-1] in line and 'r-xp' in line:
parts = line.split()
start_str = parts[0].split('-')[0]
end_str = parts[0].split('-')[1]
start = int(start_str, 16)
end = int(end_str, 16)
# 在这个段里搜索 5 个连续的 NOP (0x90)
# 这大概率是被替换的 PyErr_SetString 调用
with open('/proc/self/mem', 'rb') as mem:
mem.seek(start)
code = mem.read(end - start)
nop_seq = b'\x90' * 5
pos = 0
while True:
pos = code.find(nop_seq, pos)
if pos == -1:
break
addr = start + pos
# 检查上下文:前面是否有条件分支
ctx_start = max(0, pos - 16)
ctx = code[ctx_start:pos]
if any(b in ctx for b in [b'\x0f\x84', b'\x0f\x85', b'\x75', b'\x74', b'\x77']):
# 有控制流,可能是真正的补丁点
self._patch_ud2(addr)
print(f"[AutoPatch] UD2 @ 0x{addr:x}")
pos += 5
```
陈默还没有写完。他的眼睛停留在 `self._patch_ud2` 那一行,忽然想到一个更根本的问题。
「如果我们把所有的 `except: pass` 都在运行时触发 crash——」
「——系统会批量崩溃。」程思语接上,「生产环境承受不了。」
「对。UD2 是最好的调试工具,但最差的部署方案。」
陈默删掉了 `auto_patch_c_extension` 的整个函数体。重新开始。
这一次,他写的不再是修补。而是——重定向。
```python
# 最终方案:不修 C 扩展,也不让它崩溃
# 而是通过 LD_PRELOAD 劫持 dlopen,
# 在加载完成后、PyInit 执行前,
# 对每个 C 扩展注册一个 Python 层的异常监控
# 思路:
# 1. LD_PRELOAD 注入 shadow_dlopen.so
# 2. dlopen 完成后,在 PyInit 前,调用一个 Python 回调
# 3. Python 回调为这个 C 扩展创建对应的异常监控包装器
# 4. 所有通过 ctypes 调用 C 扩展的代码,都被自动包装 try-catch
```
程思语的声音在黑暗中响起:「你在做的——是为每个 C 函数生成一个 Python 层面的异常安全包装?」
「对。C 扩展内部的 `except: pass` 我改不了。但任何从 Python 调用 C 扩展的路径——如果 C 函数返回了错误码却没有设置异常——Python 包装器会检测到,然后主动 `raise`。」
陈默敲完最后一段代码。这是他整个二进制深潜的终点——从 `dis` 发现字节码投毒,到 `ast` 重建可信编译,到 `types.CodeType` 手写字节码,到 `sys.meta_path` 自动拦截,再到今天——`ctypes` + `dlopen` + `LD_PRELOAD` + 运行时内存修补:
```python
# 终极异常安全包装器
class CSafeWrapper:
"""为 C 扩展函数提供 Python 层的异常传播保障"""
def __init__(self, c_func, func_name, error_indicator):
self._c_func = c_func
self._name = func_name
# error_indicator: C 函数的错误返回值(如 NULL、-1)
self._error_value = error_indicator
self._signature = None
def __call__(self, *args):
result = self._c_func(*args)
# 检查返回值是否为错误指示
if result == self._error_value:
# 检查 Python 的错误标志
if not ctypes.pythonapi.PyErr_Occurred():
# C 函数返回了错误值但没有设置异常
# — 这是静默吞异常的特征
raise RuntimeError(
f"[RaiseGuard] C 扩展 '{self._name}' "
f"返回了错误码但未设置 Python 异常。"
f"这可能是一个被沉默的异常路径。"
)
return result
```
「完成了。」陈默说。
「完成了什么?」
「一个从 Python 到 C 的完整防御链。」陈默在黑暗中数着:
「第一层:`sys.meta_path` 的 `RaiseImporter`——拦截 `.py` 文件的加载。」
「第二层:`types.CodeType` 的 `RaiseGuard`——修复 Python 函数的字节码。」
「第三层:`ctypes` + `dlopen` 的 `shadow_dlopen`——监控 C 扩展的加载。」
「第四层:`CSafeWrapper`——为 C 函数提供 Python 层的异常检测。」
「四层。覆盖 Python 到 C 的每一条路径。」
程思语没有说话。黑暗中,她伸手碰了碰 ThinkPad 的屏幕边缘。
「99.7% 变成了多少?」
陈默运行了最终验证:
```python
# 最终验证:测试被修补的 C 扩展
# 场景:调用一个内部吞了异常的 C 函数
settle = importlib.import_module('_settle_core')
# 替换 C 函数为安全包装
original_func = settle.critical_process
settle.critical_process = CSafeWrapper(
original_func,
'critical_process',
-1 # C 函数用 -1 表示错误
)
# 触发出错路径
try:
settle.critical_process(bad_input_data)
print("❌ 异常被静默吞没")
except RuntimeError as e:
print(f"✓ 异常被检测并传播: {e}")
except Exception as e:
print(f"✓ 异常已传播: {type(e).__name__}")
```
终端输出:
```
✓ 异常被检测并传播: [RaiseGuard] C 扩展 'critical_process' 返回了错误码但未设置 Python 异常。这可能是一个被沉默的异常路径。
```
一行输出。但这一行意味着,从 Python 源码到 C 扩展二进制,再没有任何一条异常路径能够沉默。
程思语把手机按亮。时间:06:13。外面,科学岛的晨光透过机房的通风口,在地面上投下一条细长的光带。
「天亮了。」她说。
陈默站起来。他的膝盖因为久坐而发僵,但眼睛亮着——不是兴奋的光,是那种连续作战到极限之后仍然没有熄灭的东西。
「还有多久?」
「不确定。」程思语说,「但科学岛的大门——我刚才进来的时候,看到两辆黑色轿车停在环湖路上。」
陈默没有说话。他把 `shadow_dlopen.so`、`injector.py`、以及完整的 `RaiseGuard` 链打包成一个文件——`raiseguard_complete.so`。
「现在呢?」程思语问。
陈默看着屏幕上打包完成的输出,然后看向通风口那条越来越亮的晨光。
「现在——」他说,「去找你爸。」
程思语怔住了。
「不是用枪。不是用法律。是用代码。」陈默把笔记本合上,「他花三十年建了一座用 NOP 和 `except: pass` 堆起来的大厦。现在我用一个 `dlopen` 钩子,找到了他的地基漏洞。」
他站起来,把笔记本夹在腋下。
「他有两个选择——坐下来谈怎么修,或者——」
「或者什么?」
「或者我在所有人面前,运行这个 `raiseguard`。然后全世界都会看到神码 AI 在金融、医疗、物流——每一个系统里——有多少异常被系统性沉默了。」
程思语看着陈默。晨光已经照亮了她的脸。
「你说的'去找你爸'——」她说,「不是去打架。」
「是去 `import` 一个 `__future__`。」
陈默朝门口走去。程思语站在原地,看着他的背影在光里拉长。
然后她跟了上去。
他们走出机房,走进科学岛主楼的大厅。晨光透过积满灰尘的玻璃窗,在地上投下菱形的光斑。
大厅门口,两辆黑色轿车的引擎声在几十米外停住了。
车门打开的声音。
脚步声。
陈默停下脚步。程思语站在他旁边。
他没有回头去看那个站在晨光里的人。他只是在心里把最后一层防御链过了一遍——`dis`、`ast`、`types.CodeType`、`sys.meta_path`、`ctypes`、`dlopen`、`LD_PRELOAD`。
每一层都确认完毕。
「来人了。」程思语低声说。
「我知道。」
陈默把 ThinkPad 的电源线在手腕上绕了一圈。屏幕还亮着,光标停在终端最后一行的末尾:
```
[RaiseGuard] All layers active. 100% coverage.
```
百分之百。
再也没有沉默的异常。
脚步声越来越近。陈默望着大厅门口的光,等待第一个走进光里的人影。
他没有等太久。