Logos Forum — adversarial security audit
Pass 2: hunting for what a malicious peer could do that neither the earlier review nor the submission's own claims asked about · repo edenbd1/lp-0026-forum, HEAD 5fd43e0 · companion to the full review at logos-forum-review/
This app's wire protocol is much smaller than Forumer's, and almost all of it is already defended correctly. Every actual post is signed and verified before it's stored; the rate limiter correctly covers both outgoing posts and outgoing history answers; the one fully unsigned message type that looks for other history (kAnnounceTag) only acts on requests the receiving node itself made, closing off the obvious abuse.
The gap is in the third, smallest message type: logos-forum-want, a history request. It is unsigned by the engine's own design ("it carries no authority, only a place to look") — but that design reasoning covers authority, not cost. Reading receive() shows that every incoming want triggers a real SQL COUNT(*) query against the full posts table before any cooldown is checked. Built a probe against the real, unmodified engine to measure this directly: 5,000 trivially-generated, unsigned messages, 5,000 real queries, no throttling of any kind required to make that happen. This is the same class of finding as the companion Forumer audit's best result — a message type that costs the receiver real work and has no rate limit — reproduced here with a stronger probe, since this app's receiving logic lives in a portable library this audit could build and run directly, not behind a Qt wall.
What was already known, and whether it's still true
The earlier review's one real defect — the false "no QRegularExpression anywhere" claim, and its functional correctness and downstream use — is covered in the companion full review, not re-litigated in detail here. From a pure security-audit angle specifically: the fields this regex extracts (rlnPayer_, rlnNeeds_, rlnPrice_, rlnHolds_) are stored as QString and used only for display — a status banner and a copy-to-clipboard button. None reach a shell command, a file path, or a SQL query. A maliciously crafted detail string can at most fail to match (showing stale or blank fields) or, per the codebase's own documented concern, risk the JIT-trap under Basecamp's hardened runtime. No injection or memory-safety issue was found downstream of this specific parse — this is a correctness/documentation defect, not an exploitable one, confirmed by tracing where its output goes.
Finding: unsigned "want" (history-request) messages cost the receiver a real, unthrottled query
No rate limit or cost gate on the one message type the engine accepts without a signature
core/src/engine.cpp's receive() tries to decode every incoming payload as a signed Post first. When that fails, it checks whether the payload looks like one of three small control messages. The first, tagged logos-forum-want, is a peer saying "here's how many posts I have, tell me if you have more" — and the engine's own comment explains why it carries no signature: "it carries no authority, only a place to look, and everything fetched from there is verified post by post." That's the right call for authority. It says nothing about cost, and the handler doesn't gate cost either:
if (a.is_object() && int_at(a, kWantTag) == 1 && str_at(a, "forum") == forum_) {
const int64_t theirs = int_at(a, "have");
const size_t ours = store_.count(forum_); // real SQL COUNT(*), runs unconditionally
const int path = str_at(a, "via") == "delivery" || !on_history_wanted ? 1 : 0;
const uint64_t every = path == 1 ? kDeliveryAnswerEveryMs : kAnswerEveryMs;
const bool quiet = !answered_[path] || now_ms >= last_answer_ms_[path] + every;
if (quiet && !pending_ && theirs >= 0 && static_cast<size_t>(theirs) < ours) {
// ... schedules a new pending answer, gated by `quiet`
}
return false;
}
The quiet cooldown gates whether a new answer gets scheduled — it does not gate the store_.count(forum_) call above it, which runs for every single incoming want, cooldown or not. There is also no per-sender or global rate limit on this message type at all; limiter_ (the token bucket) is only ever consulted in pump(), for what the node itself sends, never on receive.
Built probe_want_flood.cpp against the real, unmodified core/ library (no Qt, no mocked Store or Engine) to measure this directly rather than estimate it: seeded a real SQLite-backed store with 20,000 genuine signed posts, generated 5,000 distinct, unsigned want messages (plain string concatenation, no signature or proof-of-work computation needed), and fed them through the real Engine::receive().
| Metric | Result |
|---|---|
| Messages generated | 5,000, each a distinct string, ~0 ms/msg to produce |
Messages that triggered a real COUNT(*) query | 5,000 / 5,000 |
| Total time for the receiver to process all 5,000 | 4,413 ms (run 1), 3,994 ms (run 2) — ≈0.8–0.9 ms/message against a 20,000-row table |
| Signature or proof-of-work required to send one | None |
| Rate limit observed on receive | None (the token bucket only applies to what this node sends) |
In absolute terms, ~0.9ms per message against a 20,000-row table isn't itself alarming — this is a small, local-sync-oriented app, not a large public forum backend, and real forum sizes in its own e2e logs are tiny (single digits to dozens of posts). The finding is about the shape of the gap, not a proven DoS at today's scale: there is exactly one message type in this protocol that any peer can send, with no identity check and no cost, that the engine answers with guaranteed real work every time, and nothing in the code currently bounds how often one peer (or many) can do that. If forum sizes or peer counts grow, or if this ever runs where an attacker controls many Delivery identities cheaply, this is the one place that scales with attacker effort rather than being capped by it.
Suggested fix: apply the same per-path cooldown that already gates scheduling a new answer to the store_.count() call itself — e.g. skip the count entirely when !quiet, since the count's only use is the comparison the schedule-gate needs. That turns the unconditional per-message cost into a bounded, per-cooldown-window cost, consistent with how the sibling kAnnounceTag and kSnapshotTag paths are already bounded.
Checked and found clean
SQL injection
Read all 24 query strings in core/src/store.cpp. Every one is parameterized through the Stmt wrapper's bind methods (.text(), .i64()); none build SQL by concatenating attacker-controlled strings. No injection found.
Storage-CID announcements (kAnnounceTag)
Gated by asked_.find(re) — only acts on CIDs this node itself requested via a prior want. An unsolicited announcement from an uninvolved peer is dropped before any Storage fetch happens. Same self-limiting shape as Forumer's handleMediaHere, which the companion Forumer audit confirmed safe on the same reasoning.
History-bundle answers (kSnapshotTag)
Bounded by kMaxSnapshotPosts = 100000. Not independently reproduced with a real oversized payload — this sandbox has no way to construct a Delivery-transported message anywhere near that size — but the bound itself is generous enough, and Delivery's own per-message size cap sits far below what 100,000 encoded posts would need, that this is judged a secondary, likely-unreachable concern rather than a live gap.
Image / media path
This audit specifically checked for an image/media path. Searched src/ and core/ for anything resembling image handling, file attachments, or media references — found nothing. This app has no such feature at all, so there is nothing in this category to audit — stated plainly rather than silently skipped.
Crypto handling
Signing and verification both go through libsodium's crypto_sign_detached/crypto_sign_verify_detached over a canonical, domain-separated byte serialization (not raw field concatenation, which would be vulnerable to ambiguous re-serialization). Secret key material is wiped with sodium_memzero on rotation and immediately after one-time anonymous signing — confirmed by reading identity.cpp directly, not assumed from the function names.
What could not be checked
core/src/dns.cppwas not deeply audited. It parses localresolv.conf/scutiloutput for network setup — a scope decision, not an oversight: this audit's threat model is a malicious remote peer sending wire messages, and this file processes local system configuration, not attacker-controlled network input. If the threat model should include a compromised local DNS/network configuration, this file would need a separate look.- Whether the
QRegularExpressioncode path is reachable inside a real running Basecamp GUI could not be proven either way. The one real log that exercises the "awaiting_funding" detail string (docs/e2e/e2e-history-offline-0.4.0-logos.test-unfunded.out) comes from a scripted, headless e2e run (scripts/e2e-history-offline.sh), not from inside the actual Basecamp application. The one screenshot available (docs/screens/rln-sponsor-unfunded.jpg) shows a different RLN state ("gift-failed") than the one that triggers the regex. This audit can confirm the regex is reachable in the source and functionally correct, but not that it has ever actually executed inside a real Basecamp process. - No live flood test against a real running Basecamp instance. The
probe_want_floodresult is against the real core library directly, not against a live GUI process receiving messages over real Delivery — building that would mean sending unsolicited traffic to a live node, which this audit avoided per the read-only rule. - Resource exhaustion from legitimately-sized but numerous forums/peers was not modeled. This audit measured the cost of one attacker type (fabricated "want" messages); it did not attempt to estimate what a large number of distinct real peers, each behaving legitimately but simultaneously, would cost the receiver — a different, harder-to-fabricate scenario.
This is a pure code-and-probe audit: it has no accompanying media/ screenshots or video, since there is nothing to visually demonstrate that the companion full review doesn't already cover — the finding here is a measured code-path cost, not a UI-visible behavior. Environment: Linux 6.17, x86_64, g++ 12.2 with system libsodium/sqlite3. Reproduction steps in probes/README.md.