DAO V1.1 Whitelist and Beyond: Community-Led Code Review

“The whitelist issue seems more like a mechanism design problem to me, not a technical issue, as it seems not to be an incorrect implementation, but designed to so. I’m curious how was the dao v1.1 mechanism design decisions formed?”

“In short, I see a possible communication gap where design questions are being addressed by implementers rather than designers.”

Jan Xie, another independent DAO v1.1 observer

The dev team’s response demonstrates exactly this gap: it explains how the whitelist works (“general-purpose contract”, “audit tool can detect”), not why it should exist. The following checks whether the technical claims themselves hold up.

Summary

Section Issue Severity Dev Claim Verdict Evidence
3.1 On-chain SMT whitelist enforcement Critical General-purpose contract; audit tool detects NOT FIXED Contract unchanged
3.2 Backend whitelist generation Critical (none) NOT FIXED build_vote_whitelist.rs cron unchanged
3.3 Frontend SMT proof requirement Critical (none) NOT FIXED votingUtils.ts:405 proof guard unchanged
3.4 Documentation whitelist references Medium (none) NOT FIXED Docs repo unchanged
3.5 No branch removes whitelist Critical (none) NOT FIXED No whitelist-removal commits in any repo
4.1 Administrator curates voter list Critical audit tool can detect NOT FIXED Whitelist cron + DB architecture unchanged
4.2 Unverified indexer trust Critical audit tool can detect NOT FIXED Indexer unchanged
4.3 Sybil weight amplification High Has checks in place NOT FIXED No Sybil checks found in any repo
4.4 Unauthenticated update_state Critical for test, has removed FIXED Endpoint removed from proposal.rs and main.rs routes
4.5 DID-level proposal whitelist High for test, has removed FIXED --whitelist arg removed; record.rs deleted
4.6 Stale whitelist via timing Medium for test, has removed FIXED build_whitelist endpoint removed; daily staleness is accepted design
4.7 Unauthenticated build_whitelist Medium for test, has removed FIXED Endpoint removed from main.rs routes
4.8 Selective proof denial High audit tool can detect NOT FIXED Proof endpoints unchanged
4.9 Premature vote termination High audit tool can detect NOT FIXED Contract unchanged
4.10 Voter enumeration via proof endpoint Info audit tool needs it FIXED Intentionally kept for audit tooling; data already on-chain
5.1 Browser-only key storage Medium CKB wallet key reset NOT FIXED No key recovery mechanism in docs or code
5.2 8-minute voting window Critical for test, has removed FIXED test_get_vote_time_range() removed; real duration used
5.3 Whitelist ID format mismatch Medium fixed NOT FIXED RFC 3339 vs %Y-%m-%d mismatch persists
5.4 Hardcoded testnet NetworkType High for test, has removed FIXED Configurable via --ckb_net; mainnet hashes present
5.5 DAO deposit detection failures Medium todo fix PARTIALLY FIXED Dynamic net type; pw_lock_capacity "12" filter unchanged
5.6 Wrong hashes in frontend config Medium fixed FIXED All mainnet hashes corrected in token.ts

Totals: 8 FIXED, 1 PARTIALLY FIXED, 12 NOT FIXED.

Note on the “audit tool” defense

Sections 3.1, 4.1, 4.2, 4.8, and 4.9 share the same dev response: “audit tool can detect it.” No such tool exists in any examined repo. Section 4.8 (selective proof denial) is undetectable even in principle: the denial is an off-chain HTTP non-response that leaves no on-chain trace, so no audit tool, however complete, could distinguish a denied voter from one who chose not to vote.

Repository Changes

Two of seven repos changed. Each was compared at its pinned SHA (from the original review) against current HEAD.

Repository (GitHub) Fork name Pinned SHA Current HEAD Changed?
CCF-DAO1-1/ccfdao-v1.1-docs ccfdao-v11-docs 810ba726 810ba726 No
CCF-DAO1-1/ckb-dao-vote ckb-dao-vote cc0d7d0b cc0d7d0b No
CCF-DAO1-1/ckb-fund-dao-ui ccfdao-v11-ui 04083983 558888bb Yes
CCF-DAO1-1/app_view ccfdao-v11-app-view 6ebfddbe 479ef1d4 Yes
web5fans/ckb-dao-vote web5fans-ckb-dao-vote 4554d310 4554d310 No
web5fans/web5-indexer web5fans-indexer 83ec7c80 83ec7c80 No
web5fans/dao_deploy web5fans-dao-deploy 95a8a1e6 95a8a1e6 No

Changes in ccfdao-v11-app-view (backend)

5 commits: refactor ckb net type, update mainnet code hash, clean for release, update vote_time_range, clean. 13 files changed (-771 / +122 lines). Major removals: src/api/record.rs (entire file), src/relayer/stream.rs (test module), unauthenticated update_state and build_whitelist endpoints, test_get_vote_time_range(). Added: --ckb_net CLI arg with runtime NetworkType selection and mainnet code hashes.

Changes in ccfdao-v11-ui (frontend)

~180 commits across dev branch merged to main. 19 files changed in the diff from pinned SHA to HEAD (-539 / +914 lines). Major changes: mainnet token/voting hashes corrected in token.ts, depGroupTxHash field removed, token refresh logic replaced with immediate logout, account creation flow reordered, debug logging added, votingUtils.ts.bak backup file committed.

Remaining Issues

Section numbers match the original report for cross-reference. Sections not listed here (3.5, 4.4-4.7, 4.10, 5.2, 5.4, 5.6) were verified as fixed in the summary table above.

3.1 On-Chain SMT Whitelist Enforcement

Original finding: The vote type script at entry.rs:88 enforces SMT proof verification when smt_root_hash is Some(...). The backend unconditionally sets it to Some(...).

Dev claim: “The contract supports empty smt_proof to allow anyone to vote because the vote contract was designed as a general-purpose contract. In the CCFDAO context, only non-empty smt_proof is supported.”

Current code: Contract unchanged. The if root_hash.is_some() branch at entry.rs:88-108 is intact. The contract documentation at ckb-dao-vote.md:34-35 still reads: “When set: Only users included in the SMT can vote (restricted vote). When None: All users can vote (open vote).”

Verdict: NOT FIXED. The dev’s response acknowledges the design: the contract is intentionally general-purpose. In the CCFDAO deployment, the backend always populates smt_root_hash, making the whitelist mandatory for every vote. The detection the dev describes is feasible given public chain data (see note on the “audit tool” defense), but no such tool exists. The original finding stands.

3.2 Backend Whitelist Generation

Original finding: A daily cron job in build_vote_whitelist.rs builds an SMT from registered Web5 DIDs with Nervos DAO deposits.

Current code: build_vote_whitelist.rs:40-99 is unchanged. The cron expression "0 0 0 * * *" (midnight) is unchanged. The function still: iterates DID profiles, resolves CKB addresses, checks deposits, builds SMT, stores root hash. Eligibility still requires both a Web5 DID and a DAO deposit.

Verdict: NOT FIXED. Original finding stands.

3.3 Frontend SMT Proof Requirement

Original finding: votingUtils.ts requires a valid SMT proof from the backend. Line 405 rejects votes without proof.

Current code: votingUtils.ts:405-407:

if (!voteData.proof || !Array.isArray(voteData.proof)) {
  throw new Error(getT('modal.voting.errors.missingProof', t));
}

The proof validation guard is unchanged. The buildAndSendVoteTransaction function still constructs a VoteProof molecule from the backend-provided proof and embeds it in the witness outputType. The core vote flow is unchanged; the diff from pinned SHA to HEAD does not alter the vote flow.

Verdict: NOT FIXED. Original finding stands.

3.4 Documentation Whitelist References

Original finding: Documentation at faq.mdx:75, vote.mdx:140, and bind-nervosdao-address.mdx:138 describes the whitelist as mandatory.

Current code: Docs repo unchanged.

  • faq.mdx:75: “the system collects the Owner addresses corresponding to all currently registered Web5 DID accounts as the voting whitelist. Only Web5 DID accounts on the whitelist can participate in voting”
  • vote.mdx:140: “smt_proof: Cryptographic proof that this lock_script_hash is in the authorized voter whitelist”
  • bind-nervosdao-address.mdx:138: “After binding an address, it will only be included in the whitelist when the next proposal initiates voting”

Verdict: NOT FIXED. All whitelist references intact.

4.1 Insider: Administrator Curates the Voter List

Original finding: Anyone with database access can modify the vote_whitelist table and alter the voter list.

Dev claim: “audit tool can detect it”

Current code: Backend whitelist architecture unchanged. VoteWhitelist.insert uses ON CONFLICT ... UPDATE (unchanged). Both the cron job and create_vote_tx read from the vote_whitelist table. The /api/vote/whitelist endpoint that would allow community verification is broken by the ID format mismatch (Section 5.3, still unfixed).

Verdict: NOT FIXED. The dev defers to the non-existent audit tool (see note on the “audit tool” defense). The community verification endpoint (/api/vote/whitelist) remains broken.

4.2 Insider: Vote Tally Trusts an Unverified Indexer

Original finding: The tally function calls all_votes() via unauthenticated HTTP to the vote indexer.

Dev claim: “audit tool can detect it”

Current code: Indexer unchanged. The all_votes() call path in the backend is unchanged. The indexer’s DISTINCT ON(address) deduplication remains the sole voter-dedup mechanism.

Verdict: NOT FIXED. The dev defers to the non-existent audit tool (see note on the “audit tool” defense).

4.3 Design: Vote Weight Amplification via Sybil Binding

Original finding: One person with two DIDs can amplify vote weight through unilateral binding. This is the same class of attack that affected the Community Fund DAO v1.0 vote, where a single actor cast multiple votes to influence the outcome.

Dev claim: “CCFDAO has checks in place for Sybil attacks, but we’ll re-verify the specific scenarios listed in the report.”

Current code: The get_weight() function in indexer_bind.rs is unchanged. The indexer repo (web5fans-indexer) has no Sybil prevention: the DID registration handler at ckb.rs:240-309 records every DID cell on-chain with no uniqueness constraint on ckbAddress. The did_record schema has primary key (did), not (ckbAddress). The query_all_did_doc_by_ckb_addr function explicitly returns a Vec<String> of multiple DIDs per address. No code matching “sybil” or “duplicate” exists in any repo. No cross-voter deduplication exists in the tally loop.

Verdict: NOT FIXED. The claim that “CCFDAO has checks in place” is not substantiated by any code in any repo examined.

4.8 Insider: Selective Proof Denial

Original finding: The operator controls the only proof distribution path via /api/vote/prepare and /api/vote/proof.

Dev claim: “audit tool can detect it”

Current code: Both endpoints are unchanged. prepare at vote.rs:357-390 takes a PrepareBody (not SignedBody), calls get_proof() which reconstructs the SMT from the stored whitelist, and returns the proof bytes. No authentication on either endpoint. The operator can still deny proofs to specific voters.

Verdict: NOT FIXED. The dev defers to the non-existent audit tool (see note on the “audit tool” defense). Selective proof denial cannot be detected after the fact by a chain audit tool, since it does not alter the on-chain root hash.

4.9 External: Premature Vote Termination

Original finding: Consuming the VoteMeta cell closes the vote session permanently.

Dev claim: “audit tool can detect it”

Current code: Contract unchanged. The VoteMeta cell model is identical. Lock script governance is unchanged.

Verdict: NOT FIXED. Visible on-chain (see note on the “audit tool” defense).

5.1 Design: Browser-Only Key Storage

Original finding: Private signing key stored unencrypted in localStorage. Export/import is optional.

Dev claim: “Users can set a new key via the CKB wallet associated with their DID.”

Current code: The docs repo is unchanged. Searched all docs for “key rotation”, “new key”, “reset key”, “replace key”, “update key”: zero results. The only key-related docs are the export/import flow at register-web5-did.mdx:112-136, which is unchanged. The frontend at storage.ts:56 stores the signing key as a plaintext hex string in localStorage under the key @dao:client. In the new createAccount.ts:392, this signing key is also passed as the password field to PDS account creation, so the cryptographic signing material doubles as the PDS authentication credential.

Verdict: NOT FIXED. The dev’s claim of CKB-wallet-based key recovery has no evidence in any repo or documentation.

5.3 Bug: Whitelist ID Format Mismatch

Original finding: Cron job stores RFC 3339 IDs; create_vote_meta queries by %Y-%m-%d.

Dev claim: “fixed”

Current code: The mismatch persists:

  • build_vote_whitelist.rs:89: let id = chrono::Local::now().to_rfc3339(); (e.g. "2026-03-16T00:00:00+08:00")
  • vote.rs:98: let id = chrono::Local::now().format("%Y-%m-%d").to_string(); (e.g. "2026-03-16")

These IDs never match. The dead create_vote_meta function was removed, but the /api/vote/whitelist endpoint at vote.rs:97-112 still queries by the %Y-%m-%d format, meaning it cannot find rows stored with RFC 3339 IDs. The working create_vote_tx function at mod.rs:237-240 avoids the mismatch by selecting the most recent row by ORDER BY created DESC, bypassing the ID entirely.

Verdict: NOT FIXED. The dev removed the dead create_vote_meta function, but the same format mismatch affects the live /api/vote/whitelist endpoint used for community verification (Section 4.1). Community members calling /api/vote/whitelist receive a database error, not the whitelist.

N1: SQL Injection via Expr::cust(format!(...))

The following vulnerability was found during the verification. It predates the five commits examined above and was not covered by the original report or the dev team’s response.

Severity: Critical
File: proposal.rs:93, reply.rs:74, task.rs:81-84, task.rs:132-135, lexicon/proposal.rs:217

N1.1 Root cause

Five locations use Expr::cust(format!(...)) to interpolate user-supplied strings directly into SQL fragments, bypassing sea-query’s parameterized query system. Example from proposal.rs:93:

Expr::cust(format!("record #>> '{{data,title}}' like '%{q}%' ..."))

Verified against the library source (sea-query 1.0.0-rc):

  • Expr::cust() wraps its argument into Expr::Custom(s) with no processing.
  • The query builder prepare_simple_expr renders it as sql.write_str(s): zero escaping, zero parameterization, spliced verbatim into the SQL text.
  • The sea-query-sqlx bridge only handles parameterized Value bindings; it does not touch the SQL string itself.

No layer in the stack sanitizes Expr::Custom content. sea-query provides a safe alternative (Expr::cust_with_values) that uses protocol-level parameter bindings, but the app uses format!() interpolation instead.

sqlx uses the PostgreSQL extended query protocol (prepared statements), so semicolon-based multi-statement attacks (e.g. ; DROP TABLE) are rejected at the protocol level. The injection is confined to single-statement operations: WHERE-clause bypass, subquery-based data extraction, and denial of service.

N1.2 WHERE bypass

The q parameter (from POST /api/proposal/list) and viewer parameter (from GET /api/proposal/detail) are publicly accessible without authentication. The did parameter (from GET /api/task) is also unauthenticated. A crafted value containing a single quote breaks out of the string literal. Example using the q parameter at proposal.rs:93:

POST /api/proposal/list
Content-Type: application/json

{"q": "' OR '1'='1' --"}

The format!() macro produces:

record #>> '{data,title}' like '%' OR '1'='1' --%' or ...

The ' closes the LIKE string, OR '1'='1' makes the condition always true, and -- comments out the rest. All proposals are returned regardless of content.

N1.3 Data extraction

The error handler at error.rs:31-35 returns PostgreSQL error messages directly in the HTTP response body. A CAST subquery forces a type error whose message contains the extracted value:

POST /api/proposal/list
Content-Type: application/json

{"q": "' AND 1=CAST((SELECT did FROM administrator LIMIT 1) AS int) --"}

PostgreSQL rejects the cast and the error is returned to the caller:

{"code": 500, "error": "ServerError",
 "message": "exec sql failed: error returned from database: invalid input syntax for type integer: \"did:web5:abc123...\""}

The admin DID appears in the error message. Incrementing OFFSET extracts every row. The same technique reads vote_whitelist (voter lock hashes and SMT root hashes), proposal (fund receiver_addr addresses), and profile (user data). No authentication is required for any of these.

This compounds with Section 5.1: admin signing keys are stored as plaintext hex in browser localStorage (storage.ts:56) and double as PDS passwords (createAccount.ts:392). An attacker who identifies admin DIDs via this injection has a target list for key theft; obtaining the key grants full admin access, including fund disbursement via send_funds.

N1.4 Denial of service

The connection pool has 5 slots (main.rs:66). pg_sleep is available to all PostgreSQL users by default:

POST /api/proposal/list
Content-Type: application/json

{"q": "' AND pg_sleep(10) IS NOT NULL --"}

Five concurrent requests each hold a connection sleeping for 10 seconds; the pool is exhausted and every other database-dependent endpoint queues and times out. The HTTP timeout (main.rs:193-196, 10 seconds) eventually drops the connection, so sustained DoS requires repeated requests, but with 5 connections and no authentication the effort is trivial. No statement_timeout is configured on the PostgreSQL side.

Verdict: NOT FIXED. Pre-existing in the codebase and not addressed by the 5 new commits.

Recommendations

Combining the unresolved original findings with the new SQL injection: until the audit tool exists, the backend operator retains unilateral control over who can vote, which votes are counted, and what the result is, with no independent verification path available to the community.

  1. Fix the SQL injection immediately (N1). The five Expr::cust(format!(...)) sites allow unauthenticated full read access to the database, including admin DIDs, voter lock hashes, and fund receiver addresses. Replace with Expr::cust_with_values or equivalent parameterized queries.
  2. Fix the ID format mismatch (5.3). The only community verification endpoint (/api/vote/whitelist) is broken: it queries by %Y-%m-%d but the cron stores RFC 3339 IDs.
  3. Add Sybil deduplication (4.3). One person can create multiple DIDs, bind the same CKB address to each, and vote multiple times with full weight. This is the same class of vulnerability that was exploited in the Community Fund DAO v1.0 vote. Add a uniqueness constraint on ckbAddress in the whitelist build or deduplicate by deposit source in the tally.
  4. Publish the audit tool or a specification so the community can build one independently. Without it, none of the Section 4 centralization risks are independently verifiable.
  5. Remove the whitelist at all layers (contract, backend, frontend) so that the system matches the “general-purpose” framing in the dev response.
  6. Commission an independent security audit. N1 was found accidentally in a community-led review, not a security audit. A critical SQL injection hiding in plain sight suggests more vulnerabilities are waiting. The DAO should commission a professional security audit covering the full stack (contract, backend, indexer, frontend, infrastructure) before mainnet deployment.
8 Likes