不改一行源码,怎么在运行时给所有危险函数装监听器
本文是系列文章第四篇。前三篇分别讲整体架构、沙箱隔离事故、LLM 误报研判工程化。
感谢各位查看并提出意见,劳烦各位star。
问题:为什么不插桩
做 SAST + 动态验证的工具,想在运行时观测"危险函数有没有被调用、参数是什么",常见做法有两种:
第一种是插桩(instrumentation):扫描被测项目的源码,找到所有 os.system / subprocess.Popen / eval 这类调用点,在前面插入一行打点代码。比如:
# 原始代码
os.system(cmd)
# 插桩后
_dyn_probe.record("os.system", cmd)
os.system(cmd)
插桩的好处是精确,每个调用点都能精确跟踪。问题是:它改了用户的源码。你的工具跑完之后,目标项目里多了几百行 _dyn_probe.record(...) 这种代码——这在生产项目上是不可接受的。而且如果你漏了某个调用点没插?那路径就断了。
第二种是运行时 hook:不改源码,在进程启动时把所有危险函数替换成带打点的包装器。不管代码里哪个模块、以什么方式(import os; os.system(...) 还是 from os import system; system(...))调用,都走同一条带打点的路径。
OpenSoft Detect 的模块4 采用了运行时 hook 方案。核心是 Python 的 sys.meta_path Import Hook 机制,配合三层补丁 + Finder 协议,覆盖了 import 之后到 import 之前的全部路径。
整体设计:三层补丁 + Finder 兜底
补丁的核心类叫 SinkPatcherFinder。install() 做四件事:
def install(self):
self._register_builtin_originals() # 先登记所有原函数
self._register_carrier_originals()
self._patch_builtins() # 第一层:builtins
self._patch_carriers() # 第二层:承载模块
self._patch_already_loaded() # 第三层:兜底扫描
sys.meta_path.insert(0, self) # 第四层:Finder 协议
每一层解决不同的 import 方式带来的覆盖盲区。下面从每层的设计讲起。
第一层:builtins 直接替换
目标是 builtins 模块里的危险内建函数:
BUILTIN_SINKS = ("eval", "exec", "open")
注意我没有包含 compile。因为很多框架(Starlette / FastAPI 的类定义期、SQLAlchemy 的模型初始化)会高频调用 compile,包装它会在框架启动阶段产生大量无意义噪音,甚至干扰框架自身的类创建流程。实测踩过这个坑。
实现非常直接:
def _patch_builtins(self):
for name in BUILTIN_SINKS:
orig = getattr(builtins, name, None)
self._builtin_backup[name] = orig
setattr(builtins, name, self._wrap(name, orig))
_builtin_backup 存原始值,uninstall() 时恢复。这一层处理的是 eval(...) / exec(...) / open(...) 这种裸调用——不需要 import 就能用的函数。
第二层:承载模块属性替换
目标是所有"承载危险函数"的模块或类:
CARRIER_SINKS = {
"os": ("system", "popen", "remove", "rmdir", "unlink"),
"subprocess": ("run", "Popen", "call", "check_call", "check_output"),
"pickle": ("loads", "load"),
"sqlite3.Cursor": ("execute", "executemany", "executescript"),
"hashlib": ("md5", "sha1", "sha256", "new", "pbkdf2_hmac"),
"requests": ("get", "post", "put", "delete", "request"),
"jinja2": ("Template",),
"jinja2.Environment": ("from_string",),
"flask": ("render_template_string",),
# ... 共覆盖 40+ 个危险 Sink
}
覆盖的漏洞类型:命令注入、反序列化、SQL 注入、SSRF、XXE、SSTI、弱哈希等。模块名支持点路径(sqlite3.Cursor / jinja2.Environment),用 importlib.import_module + getattr 逐级解析。
关键决策:类只补 __init__,不整体替换
这层遇到的一个关键问题是:subprocess.Popen 是一个类。Popen(...) 调用的是它的 __init__。
如果把整个类替换掉:
# 错误做法
os.Popen = wrapped_popen_class # type(Popen(xxx)) 不是原来的 subprocess.Popen 了
会破坏两件事:
isinstance(p, subprocess.Popen)失败。很多框架代码里有if isinstance(proc, subprocess.Popen)的判断。- 子类化破坏。
subprocess.Popen被框架继承(比如某些异步进程管理框架),替换整个类会让继承链断裂。
正确做法是:保留类本身,只补 __init__:
def _patch_carriers(self):
for key, attrs in CARRIER_SINKS.items():
obj = self._resolve_carrier(key)
for attr in attrs:
orig = getattr(obj, attr, None)
if isinstance(orig, type):
# 类:只补 __init__
init = orig.__init__
setattr(orig, "__init__", self._wrap(attr, init))
else:
# 普通函数:直接替换
setattr(obj, attr, self._wrap(attr, orig))
这样 type(Popen(xxx)) 还是 subprocess.Popen,isinstance 正常,子类化正常,只是构造时多了打点逻辑。
第三层:兜底扫描 sys.modules
第二层只解决了 import os; os.system(...) 这种"直接属性访问"。如果代码用的是:
from os import system
system(...)
Python 会在 import 时把 os.system 这个函数对象的引用复制到当前模块的命名空间里。第二层替换了 os.system,但当前模块里的那个引用还是旧的——因为它在 import 时就复制好了。
所以第三层的任务是:扫描所有已经 import 的模块,找出里面所有"值等于危险原函数"的属性,替换成包装器。
def _patch_already_loaded(self):
for mod_name, module in list(sys.modules.items()):
if self._is_own_module(mod_name):
continue
self._patch_module_object(module, mod_name)
def _patch_module_object(self, module, mod_name):
for attr, val in list(vars(module).items()):
if not callable(val):
continue
entry = self._originals.get(id(val)) # identity 判断
if entry is not None:
sink_name, _orig = entry
setattr(module, attr, self._wrap(sink_name, val))
核心判断是 id(val) in self._originals。_originals 在 install() 的第一步就登记好了——所有危险原函数的 id() 都作为 key 存进去了。所以这层扫描的是:你模块里有任何属性的值,恰好是我登记过的危险原函数对象吗?
这个 identity 判断不关心属性名是什么。不管你写的是:
from os import system # 属性名 "system",值是 os.system
from subprocess import Popen as P # 属性名 "P",值是 subprocess.Popen
import pwn; exec(pwn.shellcode) # 属性名 "exec",值是 builtins.exec
只要值的 id() 匹配原函数,就会被替换。这是第三层能做"兜底"的根本原因——它不依赖 import 语法,只依赖对象 identity。
跳过自己的模块
扫描 sys.modules 时有个必须跳过的范围:打点器自己。
@staticmethod
def _is_own_module(fullname):
return (fullname == "modules"
or fullname.startswith("modules.")
or fullname.startswith("opensoft")
or fullname in ("__main__", "builtins"))
如果不跳过,包装器本身调用 subprocess.Popen 时会被自己的打点器捕获——死循环。
第四层:Finder 协议拦截晚加载模块
前三层在 install() 调用时就完成了,但还有一种情况:某些模块是在 install() 之后才被 import 的(比如框架的惰性加载、动态 import)。前三层替换了 os.system,但晚加载的模块在 import 时会把已经被替换过的包装器复制进来——理论上没问题。但如果前三层有遗漏?
第四层通过 sys.meta_path 的 Finder 协议拦截所有晚加载的模块:
sys.meta_path.insert(0, self) # SinkPatcherFinder 自己就是一个 Finder
实现了三个 Finder 方法:
find_module(fullname, path)(legacy 协议):判断模块是否属于allowed_prefixes,是就返回 self。load_module(fullname)(legacy 协议):先正常 import,再对该模块做_patch_module_object(和第三层一样的 identity 判断)。find_spec(fullname, path, target)(Python 3 协议,推荐):find_spec不拦截模块加载,而是拿到 spec 后替换 spec.loader 为一个薄包装,在exec_module完成后对新加载的模块做打点:
class _PatchingLoader:
def __init__(self, underlying, hook):
self.underlying = underlying
self._hook = hook
def exec_module(self, module):
self.underlying.exec_module(module) # 正常加载
self._hook._patch_module_object(module, fullname) # 加载完打点
__getattr__ 兜底把所有其它属性转发给底层 loader,保证兼容。
实际上前三层已经覆盖了 99% 的场景,Finder 作为协议扩展保留。
包装器里做了什么
三层补丁 + Finder 最终都会调用同一个 _wrap(sink_name, orig) 来生成包装器。
幂等:同一原函数只包装一次
def _wrap(self, sink_name, orig):
cached = self._wrapper_cache.get(id(orig))
if cached is not None:
return cached
# ... 构造 wrapper ...
self._wrapper_cache[id(orig)] = wrapper
return wrapper
wrapper_cache 的 key 是原函数的 id()。这意味着:import os 和 from os import system 产生的两个引用,指向同一个 os.system 对象,只会被包装一次。wrapper.__wrapped__ 指向原函数,方便 inspect.unwrap() 和栈回溯。
包装逻辑
def wrapper(*args, **kwargs):
if sink_name in self.enabled_sinks:
tracker_a.record_sink(sink_name, args, kwargs) # 轨道A:无差别记录
checker_b.check_at_sink(sink_name, args, kwargs) # 轨道B:污点检测
if policy_obj is not None:
policy_obj.check(sink_name, args, kwargs) # 策略 Oracle
return orig(*args, **kwargs) # 调用原函数(沙箱内执行,安全)
轨道 A / 轨道 B / 策略 Oracle 各做什么:
| 轨道 | 做什么 | 为什么 |
|---|---|---|
| 轨道 A | 记录所有命中的 Sink 调用(sink_name + args + kwargs + 调用栈) | 只要 Sink 被调用就证明代码路径可达;轨道 B 丢失时仍有旁证 |
| 轨道 B | 检查 Sink 参数里是否有 Canary 水印字符串 | Canary 水印从用户输入点注入,如果原样到达 Sink → 污点未清洗 |
| 策略 Oracle | 检查"弱哈希"等 Canary 天然测不到的场景 | hashlib.md5(user_input) 里 Canary 进了哈希就消失了,轨道 B 失效;改为断言"用户可控参数进入了弱算法的位置参数" |
所有检测都包在 try...except 里。打点失败绝对不能影响原函数的执行——动态探针是观察者,不是干预者。
Sink 启用集合:open 默认关闭
DEFAULT_ENABLED_SINKS 里包含了所有 BUILTIN_SINKS 和 CARRIER_SINKS 的 sink 名,但 open 默认不启用。原因是被测项目里会有大量 open(...) 调用(配置文件、日志、数据库连接字符串),全部打点会产生海量噪音。只有当某条 finding 明确指向文件操作类漏洞时,才通过 configure_sinks() 临时开启。
卸载:恢复原状
uninstall() 做四件事:恢复 builtins、恢复承载模块属性、从 sys.meta_path 移除自己、清空缓存。承载模块属性的恢复要区分普通函数和 __init__:
for attr, orig in attrs.items():
if attr.endswith(".__init__"):
cls = getattr(obj, attr[: -len(".__init__")])
setattr(cls, "__init__", orig)
else:
setattr(obj, attr, orig)
所有恢复操作都包 try...except,卸载失败不抛异常——进程即将退出,没必要因为恢复失败而崩。
设计上的几个取舍
_originals按原函数 id 登记,不按属性名。这让第三层 identity 判断成立,也让同一原函数多次引用只登记一次。compile不包含在 BUILTIN_SINKS 里。框架初始化期高频调用会产生无意义噪音。- 类只补
__init__。不整体替换,避免破坏 isinstance 和子类化。 - 包装器不干预原函数执行。所有检测包在 try...except 里,检测失败不影响被测代码的正常行为。
open默认不启用。噪音太大,按需开启。
局限性
这种 hook 方案天然有几个盲区:
- C 扩展里的内部调用不走 Python 命名空间。比如
os._exit是 C 实现,hook 不到。但它不在危险 Sink 列表里,不影响核心检测能力。 ctypes直接调 C 函数。同理,不经过 Python 的函数命名空间。exec/eval生成的字符串里的新函数定义。hook 只在 import 时生效,动态 exec 出来的代码里如果有直接调用原始函数的行为,三层补丁都挡不住——但这种情况极其罕见。- 沙箱内才安全。
os.system被 hook 了之后还是会真的执行系统命令——所以整个动态探针必须跑在 Docker 沙箱里(断网 + 副本隔离),否则 hook 本身就是个安全漏洞。
项目地址:github.com/mabupt/open…
Import Hook 核心文件:modules/dynamic_verification/import_hook.py