Python sys.meta_path 实战:用 Import Hook 给 os.system / eval 装拦截器

不改一行源码,怎么在运行时给所有危险函数装监听器

配套仓库:github.com/mabupt/open...

本文是系列文章第四篇。前三篇分别讲整体架构、沙箱隔离事故、LLM 误报研判工程化。

感谢各位查看并提出意见,劳烦各位star。

问题:为什么不插桩

做 SAST + 动态验证的工具,想在运行时观测"危险函数有没有被调用、参数是什么",常见做法有两种:

第一种是插桩(instrumentation) :扫描被测项目的源码,找到所有 os.system / subprocess.Popen / eval 这类调用点,在前面插入一行打点代码。比如:

python 复制代码
# 原始代码
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 兜底

补丁的核心类叫 SinkPatcherFinderinstall() 做四件事:

python 复制代码
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 模块里的危险内建函数:

python 复制代码
BUILTIN_SINKS = ("eval", "exec", "open")

注意我没有包含 compile 。因为很多框架(Starlette / FastAPI 的类定义期、SQLAlchemy 的模型初始化)会高频调用 compile,包装它会在框架启动阶段产生大量无意义噪音,甚至干扰框架自身的类创建流程。实测踩过这个坑。

实现非常直接:

python 复制代码
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 就能用的函数。

第二层:承载模块属性替换

目标是所有"承载危险函数"的模块或类:

python 复制代码
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__

如果把整个类替换掉:

python 复制代码
# 错误做法
os.Popen = wrapped_popen_class    # type(Popen(xxx)) 不是原来的 subprocess.Popen 了

会破坏两件事:

  1. isinstance(p, subprocess.Popen) 失败 。很多框架代码里有 if isinstance(proc, subprocess.Popen) 的判断。
  2. 子类化破坏subprocess.Popen 被框架继承(比如某些异步进程管理框架),替换整个类会让继承链断裂。

正确做法是:保留类本身,只补 __init__

python 复制代码
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.Popenisinstance 正常,子类化正常,只是构造时多了打点逻辑。

第三层:兜底扫描 sys.modules

第二层只解决了 import os; os.system(...) 这种"直接属性访问"。如果代码用的是:

python 复制代码
from os import system
system(...)

Python 会在 import 时把 os.system 这个函数对象的引用 复制到当前模块的命名空间里。第二层替换了 os.system,但当前模块里的那个引用还是旧的------因为它在 import 时就复制好了。

所以第三层的任务是:扫描所有已经 import 的模块,找出里面所有"值等于危险原函数"的属性,替换成包装器。

python 复制代码
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_originalsinstall() 的第一步就登记好了------所有危险原函数的 id() 都作为 key 存进去了。所以这层扫描的是:你模块里有任何属性的值,恰好是我登记过的危险原函数对象吗?

这个 identity 判断不关心属性名是什么。不管你写的是:

python 复制代码
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 时有个必须跳过的范围:打点器自己。

python 复制代码
@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_pathFinder 协议拦截所有晚加载的模块:

python 复制代码
sys.meta_path.insert(0, self)   # SinkPatcherFinder 自己就是一个 Finder

实现了三个 Finder 方法:

  1. find_module(fullname, path) (legacy 协议):判断模块是否属于 allowed_prefixes,是就返回 self。
  2. load_module(fullname) (legacy 协议):先正常 import,再对该模块做 _patch_module_object(和第三层一样的 identity 判断)。
  3. find_spec(fullname, path, target) (Python 3 协议,推荐):find_spec 不拦截模块加载,而是拿到 spec 后替换 spec.loader 为一个薄包装 ,在 exec_module 完成后对新加载的模块做打点:
python 复制代码
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) 来生成包装器。

幂等:同一原函数只包装一次

python 复制代码
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 osfrom os import system 产生的两个引用,指向同一个 os.system 对象,只会被包装一次。wrapper.__wrapped__ 指向原函数,方便 inspect.unwrap() 和栈回溯。

包装逻辑

python 复制代码
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__

python 复制代码
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,卸载失败不抛异常------进程即将退出,没必要因为恢复失败而崩。

设计上的几个取舍

  1. _originals 按原函数 id 登记,不按属性名。这让第三层 identity 判断成立,也让同一原函数多次引用只登记一次。
  2. compile 不包含在 BUILTIN_SINKS 里。框架初始化期高频调用会产生无意义噪音。
  3. 类只补 __init__。不整体替换,避免破坏 isinstance 和子类化。
  4. 包装器不干预原函数执行。所有检测包在 try...except 里,检测失败不影响被测代码的正常行为。
  5. 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

相关推荐
小程序设计1 小时前
校园网络资产自动探测与安全状态评估系统设计与实现
网络·安全
小程序设计2 小时前
电力大模型输出内容安全风险评估和控制系统
安全
Wang's Blog2 小时前
Java框架快速入门: Spring Security+OAuth2之前端按钮级安全与路由守卫
java·安全·spring
judezh3 小时前
验证一个容器镜像到底在验什么?我把自家 v1.0.0 的签名从注册表一路扒到了证书里
安全·开源
Bruce_Liuxiaowei15 小时前
2026年9月第2周网络安全形势周报
网络·安全·web安全·网络安全·漏洞评级
hasty16 小时前
Origin 校验不是身份认证:MySQL MCP Server 漏洞给 AI 平台的警告
数据库·mysql·安全
小杨不想秃头17 小时前
《信息安全工程师教程(第2版)》通关笔记
安全
workflower19 小时前
人形机器人安全伦理标准
人工智能·安全·机器学习·机器人·无人机
我不是程序员三三20 小时前
内网监控系统|终端监控上线验收与常态化安全自查清单
安全