Bypassing File Upload Restrictions To Exploit Client-Side Path Traversal
09 Jan 2025 - Posted by Maxence Schmitt
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”.