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);
- 描述:这些函数在 NULL (
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>')
将输出<script>alert("XSS")</script>
- 用法:
htmlspecialchars($input, ENT_QUOTES, 'UTF-8');
- 描述:将特殊字符(如
htmlentities
- 描述:将所有的适合的字符转义为HTML实体,与
htmlspecialchars
类似,但更加严格。 - 例子:
htmlentities('<b>bold</b>')
返回<b>bold</b>
- 用法:
htmlentities($input, ENT_QUOTES, 'UTF-8');
- 描述:将所有的适合的字符转义为HTML实体,与
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"
- 禁用危险函数:通过PHP的配置文件
in_array() 缺陷
分析上面的代码流程,是一个文件上传的接口,但是对文件名有白名单,利用了in_array()
方法,这个
1 | in_array :(PHP 4, PHP 5, PHP 7) |
由于该函数并未将第三个参数设置为 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 | //index.php |
题目可以看出,首先需要绕过in_array之后由于sql语句过滤,需要考虑绕过。前者加个数字就好,后者可以用 updatexml 注入。当 updatexml 中存在特殊字符或字母时,会出现报错,报错信息为特殊字符、字母及之后的内容,也就是说如果我们想要查询的数据是数字开头,例如 7701HongRi ,那么查询结果只会显示 HongRi 。所以我们会看到很多 updatexml 注入的 payload 是长这样的 and updatexml(1,concat(0x7e,(SELECT user()),0x7e),1) ,在所要查询的数据前面凭借一个特殊符号(这里的 0x7e 为符号 ‘‘ )。使用下面的payload可以获得题解:‘,(select flag from flag)),1))`
`http://localhost/index.php?id=4 and (select updatexml(1,make_set(3,’
Twig 过滤不充分
题目叫做Twig,代码如下:
漏洞解析 :
这一关题目实际上用的是PHP的一个模板引擎 Twig ,本题考察XSS(跨站脚本攻击)漏洞。虽然题目代码分别用了 escape 和 filter_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 & (& 符号) =============== &
" (双引号) =============== "
' (单引号) =============== '
< (小于号) =============== <
> (大于号) =============== >第二处过滤在 第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 | function current_url() { |
我们跟进到 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 | // index.php |
可以看出来只需要绕过两个部分,第一个是 filter_var 的过滤,第二个是 preg_match 对 url 的判断,是否是以sec-redclub.com结尾。对于filter_var可以用下面的方式绕过
1 | http://localhost/index.php?url=http://demo.com@sec-redclub.com |
接着要绕过 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 为类的名字,在匹配的时候不区分大小写。默认情况下 $autoload 为 true ,当 $autoload 为 true 时,会自动加载本程序中的 __autoload 函数;当 $autoload 为 false 时,则不调用 __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 | GET /shopware520/backend/ProductStream/loadPreview?_dc=1530963660916&sort={"Shopware\\Bundle\\SearchBundle\\Sorting\\PriceSorting":{"direction":"asc"}}&conditions={}&shopId=1¤cyId=1&customerGroupKey=EK&page=1&start=0&limit=2 |
我们可以看到 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 |
|
我们发送payload,并用xdebug调试程序,最后程序将我们读取的值存储在 $conditions 变量中,如下图所示:
关于PHP中XXE漏洞的修复,我们可以过滤关键词,如: ENTITY 、 SYSTEM 等,另外,我们还可以通过禁止加载XML实体对象的方式,来防止XXE漏洞(如下图第2行代码),具体代码如下:
我感觉上面实例的利用关键点在于,类的初始化和参数的传递都是我们可控的。
题目
1 | // index.php |
- 定义一个
NotFound
类1
2
3
4
5
6class NotFound{
function __construct()
{
die('404');
}
}
- 这个
NotFound
类有一个构造函数,该函数调用了die('404');
。这意味着当这个类被实例化时,脚本会立即终止并输出404
。这通常用于表示未找到(如页面或资源)。
- 注册一个自动加载函数
1
2
3
4
5spl_autoload_register(
function ($class){
new NotFound();
}
);
spl_autoload_register
函数用于注册任意数量的自动加载器,这些加载器在PHP执行过程中试图使用未定义的类或接口时被调用。- 这里注册的匿名函数在尝试自动加载一个类时创建一个
NotFound
类的实例,因此无论什么类名被请求,都将输出404并终止执行。
- 获取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请求读取
name
、param
和param2
参数,并将它们保存在变量$classname
、$param
和$param2
中。如果这些GET参数不存在,则相应的变量被设置为null
。
- 检查类是否存在并实例化
1
2
3
4
5
6if(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¶m=./*.php¶m2=0 |
我们将会发现flag的文件名为 f1agi3hEre.php ,接下来我们使用内置类 SimpleXMLElement 读取 f1agi3hEre.php 文件的内容,,这里我们要结合使用PHP流的使用,因为当文件中存在: < > & ‘ “ 这5个符号时,会导致XML文件解析错误,所以我们这里利用PHP文件流,将要读取的文件内容经过 base64编码 后输出即可,具体payload如下:
1 | http://localhost/CTF/index.php?name=SimpleXMLElement¶m=<?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>¶m2=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 的情况,因为 false 和 0 的取反均为 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 )。当没有设置 safequestion 和 safeanswer 的值时,它们的值均为空字符串。第11行的if表达式也就变成了 if(‘0’ == ‘’ && null == ‘’) ,即 if(false && true) ,所以我们只要让表达式 $row[‘safequestion’] == $safequestion 为 true 即可。下图是 null == ‘’ 的判断结果:
我们可以利用 php弱类型 的特点,来绕过这里 $row[‘safequestion’] == $safequestion 的判断,如下:
通过测试找到了三个的payload,分别是 0.0 、 0. 、 0e1 ,这三种类型payload均能使得 $row[‘safequestion’] == $safequestion 为 true ,即成功进入 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’] ,如果相等就完成重置密码操作,至此也就完成了整个攻击的分析过程。
漏洞验证
我们分别注册 test1 , test2 两个账号
第一步访问 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 。具体修复代码如下: