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.

1 gapunsigned "want" messages: unthrottled COUNT query per message
0 injectionfound — all 24 SQL queries parameterized, no image feature exists
5000/5000fabricated "want" messages each triggered a real query (measured, not estimated)

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

needs attention

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().

MetricResult
Messages generated5,000, each a distinct string, ~0 ms/msg to produce
Messages that triggered a real COUNT(*) query5,000 / 5,000
Total time for the receiver to process all 5,0004,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 oneNone
Rate limit observed on receiveNone (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

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.

clean

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.

clean

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.

clean — no feature exists

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.

clean

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

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.