0%

PHP中为什么fopen()函数能以路径为参数创建文件?🐘

之所以写这一篇博客,是因为XCTF攻防世界的一道题目:ics-07。

题目

先来看看题目,我的疑惑也是在做题的时候产生的。

点进去是一个平台管理中心页面,试了下发现只有“项目管理”一栏是可以点的,点进去的页面如下:

乍一看以为是sql注入,但是留意到下面的view-source按钮,点开来看,获得了源代码。

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
62
63
64
65
66
67
68
69
70
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>cetc7</title>
</head>
<body>
<?php
session_start();

if (!isset($_GET[page])) {
show_source(__FILE__);
die();
}

if (isset($_GET[page]) && $_GET[page] != 'index.php') {
include('flag.php');
}else {
header('Location: ?page=flag.php');
}

?>

<form action="#" method="get">
page : <input type="text" name="page" value="">
id : <input type="text" name="id" value="">
<input type="submit" name="submit" value="submit">
</form>
<br />
<a href="index.phps">view-source</a>

<?php
if ($_SESSION['admin']) {
$con = $_POST['con'];
$file = $_POST['file'];
$filename = "backup/".$file;

if(preg_match('/.+\.ph(p[3457]?|t|tml)$/i', $filename)){
die("Bad file extension");
}else{
chdir('uploaded');
$f = fopen($filename, 'w');
fwrite($f, $con);
fclose($f);
}
}
?>

<?php
if (isset($_GET[id]) && floatval($_GET[id]) !== '1' && substr($_GET[id], -1) === '9') {
include 'config.php';
$id = mysql_real_escape_string($_GET[id]);
$sql="select * from cetc007.user where id='$id'";
$result = mysql_query($sql);
$result = mysql_fetch_object($result);
} else {
$result = False;
die();
}

if(!$result)die("<br >something wae wrong ! <br>");
if($result){
echo "id: ".$result->id."</br>";
echo "name:".$result->user."</br>";
$_SESSION['admin'] = True;
}
?>

</body>
</html>

通过阅读源码我们得知首先需要让SESSION的变量admin的值为真。

首先是一行php比较运算符:

if (isset($_GET[id]) && floatval($_GET[id]) !== '1' && substr($_GET[id], -1) === '9')

我们需要构造一个GET传参的id,使其浮点类型与'1'不全等,且该变量最后一位是'9',同时服务器用该sql查询语句进行查询需要返回结果。这里我们可以构造id=1-9。floatval()函数的功能是将一个变量转化为浮点类型,当转换过程中遇到特殊字符便不再继续转换,所以1-9将会被转换为1,而浮点类型和字符类型必然不全等,所以第二个很容易绕过。

这时候问题来到了第二段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
if ($_SESSION['admin']) {
$con = $_POST['con'];
$file = $_POST['file'];
$filename = "backup/".$file;

if(preg_match('/.+\.ph(p[3457]?|t|tml)$/i', $filename)){
die("Bad file extension");
}else{
chdir('uploaded');
$f = fopen($filename, 'w');
fwrite($f, $con);
fclose($f);
}
}
?>

chdir是切换目录,我们可以发现目录切换到了uploaded,很明显到这里就是文件上传了。fopen是打开一个文件,如果没有就创建一个新文件。$con是我们要写入文件的内容,这里选择写入一个一句话木马。但是注意到前面有一个正则的过滤语句:

if(preg_match('/.+\.ph(p[3457]?|t|tml)$/i', $filename))

这句话的意思就是过滤掉所有以php或其变体为后缀的文件。linux下有一个mime.types文件,我们可以在里面找到所有的php后缀:

1
2
3
4
5
6
7
$ cat /etc/mime.types | grep php
#application/x-httpd-php phtml pht php
#application/x-httpd-php-source phps
#application/x-httpd-php3 php3
#application/x-httpd-php3-preprocessed php3p
#application/x-httpd-php4 php4
#application/x-httpd-php5 php5

这里可以看出,所有的php文件后缀都被过滤了,而phps和php3p是源代码文件,无法作为脚本被执行。在这个地方,我在网上搜了很多个师傅的博客,他们的答案都无法让我满意,我总觉得很多的解释都是错误的。

关键的地方

事实上,file只要为123.php/.就可以成功绕过正则,但是问题是,为什么php可以把一个路径当成文件来创建呢?这里我百思不得其解。网上的答案也是五花八门:

利用了一个Linux的目录结构特性, 创建了一个目录为1.php , 在 1.php 下创建了一个子目录为 2.php . Linux下每创建一个新目录 , 都会在其中自动创建两个隐藏文件。其中 .. 代表当前目录的父目录 , . 代表当前目录 , 所以这里访问 ./1.php/2.php/.. 代表访问 2.php 的父目录 , 也就是访问 1.php

在明白原因之后我觉得很多师傅的解答简直是误人子弟。这跟linux又有什么关系呢?按照这个说法,123.php的类型很显然是目录,即便是通过..创建了123.php,但是目录类型的文件也是不能被当作php脚本来执行的。我觉得,在这个地方,网上百分之90以上的witeup都是知其然而不知其所以然。

我把这个题目放到了群里去问SUS的师傅们,最终弄明白了为什么,这里要感谢马师傅,他一下就觉得是php的问题,推给我的文章让我真正弄明白了这个问题。同时也感谢写文章的Dlive师傅,他的钻研精神让我很敬佩。

很幸运,Dlive师傅遇到的也是同一道题目,他通过调试php源码,得出了问题是出在php的fopen()方法上。这里我对他的结果做一个解释:

事实上,在参数传入fopen()方法中被调用之前,fopen()首先会调用一个expand_filepath函数,也就是在这个函数之后,原本的'123.php/.'路径转换成了'123.php',使得php得以正常创建文件。

在expand_filepath函数中,会调用一个tsrm_realpath_r对路径进行递归标准化,避免路径中存在//以及/./等问题,之后真正送入fopen方法创建文件的路径实际上就是'123.php'。看到这里我恍然大悟。也就是说tsrm_realpath_r这个函数实际上会将任何路径中的../以及/.之类的操作符给消去,得到真正的路径,也就是路径规范化。123.php其实在创建之初就不是一个目录的类型,而是php脚本类型。

感悟

在做这道题目的过程中,我翻遍了许多人的博客,都没有得到满意的答案,但是并没放弃,最后终于明白了为什么。我最大的感受就是,并不一定大家都认可的观点就一定是正确的,对待每一个似是而非的问题都不能轻易地下结论,接受别人的观点,而是要刨根问底,真正弄明白为什么。