Sample audits · Earlier documentation audits

Solana Mobile stack, third audit, September 2026

Solana Mobile - Third Security Audit. Published in full as delivered; only internal notes and delivery correspondence were removed.

AuditedSolana Mobile stack, third audit
Date10 September 2026
How it ranearlier documentation audit; severities are as rated in that report and were not re-rated

Related: Solana Mobile documentation and SDKs (June 2026) · Solana Mobile developer documentation (October 2026)

27
Critical or High as first rated
60
Medium as first rated
54
Low as first rated
12
Informational as first rated

Severities as published in our original report; these findings were not re-reviewed by hand the way the sample audits were. The 11 October 2026 re-check status is shown under each finding where it maps.

Solana Mobile - Third Security Audit


Summary

This is our third review of the Solana Mobile stack. It found 153 new findings: no Critical, 27 High, 60 Medium, 54 Low, 12 Advisory.

Four of the nine repositories covered had never had an external review of any kind - the CLI, the templates repository, the agent-skills repository, and the wallet registry. The Kotlin RPC and Web3 libraries and the Nostr relay transport had not been covered by our earlier reports either.

Two findings we would ask you to look at ahead of everything else, regardless of scope:

We make no claim that any of this falls inside your bug bounty scope. Most of it plainly does not: these are correctness and robustness defects in developer-facing libraries and documentation, not exploitable paths against user funds. We are reporting them because they are real and because they are cheaper to fix now than after adoption.

Every claim below is reproducible from your own public repositories. Where a finding asserts that something is absent, the control that proves the search would have found it is stated inline.


Scope and method

Conducted 10 September 2026 against a freshly fetched snapshot, and covering the whole substrate rather than documentation alone: 72 documentation pages, zero fetch failures, and fifteen repositories. Nothing was fetched from you beyond public HTTP and git clone. No account was used, no rate limit was tested, no non-public surface was touched.

Four of the audited repositories had never been reviewed in either earlier disclosure, three because they did not exist when those were written:

RepositoryHeadPrior coverage
solana-mobile-cli10 Sep 2026did not exist
templates10 Sep 2026did not exist
solana-mobile-skills4 Sep 2026did not exist
web-shell21 Apr 2026never opened
rpc-core3 Sep 2026named as uncovered in our own sign-off
web3-core3 Sep 2026named as uncovered in our own sign-off
mobile-wallet-adapter-registry3 Sep 2026named as uncovered in our own sign-off

Findings are numbered B80 to B232, continuing the series from the June report. Numbering was verified contiguous with no gaps and no duplicates. Every finding was re-verified against the cited file and line before it entered this document.

A note on how we treat absence. Where this report says a defect is not present, it states the control: the same search run against a case known to contain the defect, with its hit count. A search that finds nothing proves nothing unless it can be shown capable of finding something. Every zero in this document carries that control.

Two limits we are explicit about. Seven of the nine repositories were cloned at depth one, so defects in them can be dated no more precisely than present at head on 10 September 2026. And one finding, B191, concerns an anchor slug whose behaviour depends on your renderer; we could not settle it without rendering, so it is reported as unverified rather than as a defect.

Severity distribution

SeverityCount
Critical0
High27
Medium60
Low54
Advisory12
Total153

The findings that matter most

web3-core - the Kotlin transaction library cannot build a correct transaction

Three independent High findings in the path every consumer uses.

B128 - the message builder emits wrong header counts, and the test suite encodes the wrong values as correct.

val signers = writableSigners + readOnlySigners
val accounts = signers + writableNonSigners + readOnlyNonSigners + programIds
...
readOnlySigners.count { it !in signers }.toUByte(),

Message.kt:78-79, :92. Every element of readOnlySigners is by construction inside signers, so the read-only-signer count is always zero. Program IDs are appended after the read-only block and never counted, so the read-only-unsigned count addresses the wrong tail: for [payer, sysvar(read-only), program] the runtime reads program as the read-only account and takes a write lock on the sysvar. Simple single-signer transactions still land, which is what conceals this. The unit tests assert the incorrect values, so the suite defends the defect.

B129 - multi-signer transactions cannot be serialized at all.

Transaction(MutableList(transactionMessage.signatureCount.toInt()) { ByteArray(ownerLength) }.apply {

SolanaSigner.kt:24, where ownerLength is 32. A signature is 64 bytes. The serializer hard-checks the length, so any message requiring two or more signatures throws. The same construction appears in the Mobile Wallet Adapter signer module.

B130 - signatures are written in argument order, not in the order the message requires.

return Transaction(signers.map { it.signPayload(serializedMessage).getOrThrow() }, this)

Message.kt:171. No reordering against the message's account list and no count check. Passing signers in any order but the message's produces a transaction that fails signature verification on chain.

mobile-wallet-adapter - the new Nostr transport

B155 - High. The dApp side adopts the first party that answers, so the relay can impersonate the wallet. The wallet side correctly pins its counterpart to the key carried in the association URI. The dApp side accepts whichever public key first publishes an empty-content event tagged with the session identifier, then pins to that (transact.ts:849). The session identifier is the SHA-256 of the association public key, and it is sent to the relay in clear text in the subscription filter. The relay therefore holds everything needed to answer first, receive the handshake, and return its own response - which is unauthenticated (parseHelloRsp.ts:15-25). The transport this replaces used a secret random pairing identifier (reflectorId.ts:11-13); this one downgrades that to a public, derivable one. Your specification says at line 111 that the relay "should be viewed as a potential adversary" and then at line 439 mandates the first-wins rule. The code conforms to the specification. The specification contradicts its own threat model.

B156 - High. One malformed packet from the relay ends any session, at any moment, including mid-signing. hexToBytes has no odd-length and no non-hex guard. The sig field is never length-checked before conversion, because the length guard lives inside schnorrVerify, which is reached only after all three conversions are evaluated. An odd-length sig reads past the end of the string and throws, and that exception matches none of the four catch blocks in the path: onMessage has no try/catch, two intermediate handlers catch only JSONException, and the file's single catch (Exception) wraps socket construction rather than message handling. It escapes into the WebSocket library's reader, which routes it to onError, which closes the session. The intended behaviour was to discard the event and continue. Your JavaScript implementation of the same protocol is hardened against this and is tested for it; the Java side has 46 cryptographic tests and none that reaches hexToBytes with an odd-length or non-hex string. It does test invalid signature and public-key lengths, but those enter schnorrVerify as byte arrays, after the conversion that throws.

B157 - High. The two error-code tables disagree, and each file carries a comment instructing the reader to keep it synchronised with the other.

ProtocolContract.java:101 says *"Keep these in sync with mobile-wallet-adapter-protocol/src/errors.ts."* errors.ts:62 says *"Keep these in sync with mobilewalletadapter/common/ProtocolContract.java."* They are not in sync:

NameSpecificationJavaTypeScript
ERROR_NOT_CLONED-5-5absent
ERROR_TOO_MANY_PAYLOADS-6-6-5
ERROR_CHAIN_NOT_SUPPORTED-7-7, still named ERROR_CLUSTER_NOT_SUPPORTEDabsent

A wallet rejecting a request for too many payloads emits -6, which every JavaScript consumer decodes as an unknown code. A wallet emitting -5 for not cloned is mis-reported to the dApp as "too many payloads". And ERROR_CHAIN_NOT_SUPPORTED, which your specification requires authorize to return, cannot be named in JavaScript at all. The stale Java constant name is a residue of the same chain/cluster migration as our B2.

mobile-wallet-adapter-registry - a published registry that nothing reads

B143 - High. The schema pins the protocol version to a single permitted value, "1.0" - a string your specification has never used. The specification declares version 2.0.0 and its changelog lists 1.0.0, 2.0.0 and 2.1.0. No wallet can state its actual protocol level, and all four entries carry the same inert token.

B144 - High. Nothing consumes this registry. Searching both mirrors and the whole documentation corpus for its name, its raw URL or its schema returns one hit, and that hit is our own mirror report. Controls, as matching-file counts: the same pattern inside the registry itself returns 5, confirming it matches; the sibling repository name across the same roots returns 275, confirming the roots are right; the word "registry" across all 72 documentation pages returns 0. Wallet discovery happens by Android intent resolution. The repository nonetheless solicits third-party submissions and attaches a licence grant to them.

Documentation - samples that cannot run

B152 - High. The documented Kotlin dependency set cannot compile the documented Kotlin quickstart. The installation page lists com.solanamobile:rpc-core. The quickstart two pages later imports com.solana.rpc.SolanaRpcClient and com.solana.networking.KtorNetworkDriver and constructs both. Those classes ship in rpc-solana and rpc-ktordriver, which the documentation never mentions. Your own example application declares all three (examples/example-clientlib-ktx-app/app/build.gradle:86-88). The correct dependency set exists in your tree and did not reach the page a new developer follows.

B214 - High. The web compatibility statement names a package scope that does not exist. get-started/web/apps.md:25 states the library is *"compatible with any web app using the @anza/wallet-adapter libraries"*. That scope appears exactly once in your entire substrate - this sentence. Every other page and every package manifest uses @solana/wallet-adapter-*; the control returns 8 hits in the documentation, including on the sibling page.

**B192 - High. await inside a non-async arrow function, two sites.** get-started/web/ux-guidelines.md:45-49 and :87-96. Neither of the page's two connect samples parses. This is the same class as our B7, which you repaired in June on a different page.

B198 - High. The disconnect sample wipes the host application's entire key-value store. caching-wallet-authorization.md:352 calls AsyncStorage.clear() after writing exactly two keys, destroying every unrelated preference the developer's app owns.

B200 - High. The canonical response shape for your most-used protocol method is invalid JSON - two missing commas in a json-tagged block (mobile-wallet-adapter.md:294-299). Six of the corpus's sixteen json blocks fail to parse.

B220 - High. Two Seeker build identifiers are malformed. mr5.md:21 gives Seeker.2501016.001 and mr6.md:21 gives Seeker.2501027.001 - seven date digits where all nine other identifiers across the six pages use six. This is the exact string an owner compares against their device's About screen, so for European devices on two releases it can never match.

B229 - High. The sample-application page is anchored on repositories frozen in 2024 and never mentions the maintained replacements. Three of its cards point at repositories last committed on 3 June 2024, 29 May 2024 and 29 May 2024. The page carries no currency marker of any kind - controlled zero for last updated, archived, deprecated, templates and the CLI's creation command, against six references to the frozen repository. templates and solana-mobile-cli were both updated the day of this snapshot, and fourteen other pages do mention them.

B178 - High. Five live cross-page links resolve only because the platform falls through to the file tree, and B179 records the context: 60 of your 132 source pages render in no index at all - absent from the sitemap, from the machine-readable index, and from the navigation. Five of the sixty are linked from live pages; four are the destination of a legacy redirect. This is the estate behind 1.5.

web-shell - a superseded package that is still published

The CLI's webshell command replaced the standalone @solana-mobile/webshell-cli; your changelog says so. Every security-relevant difference between the two WebView templates is a fix present in the successor and absent in the predecessor. The predecessor remains published, installable, and carries no deprecation notice, so its defects are live.

The remaining findings by component

ComponentIDsCountHighest
solana-mobile-cliB80-B9011Medium
web-shell (superseded, published)B91-B10010High
templatesB101-B11414Medium
solana-mobile-skillsB115-B1184Medium
rpc-coreB119-B1279High
web3-coreB128-B14215High
mobile-wallet-adapter-registryB143-B1519High
Nostr transport and MWA re-readB155-B17723High
DocumentationB152-B154, B178-B23258High

Selected items not detailed above:

We flag that last group specifically. Instructions consumed by a person carry an implicit review step - they read, they copy, they look at it before it runs. Instructions consumed by an agent do not. A dead link becomes a prompt to confabulate; an unpinned auto-consented install becomes remote code execution in a developer's tree; a contradiction between a charter and a skill becomes a coin-flip for whichever file was loaded.

What is good, stated as plainly as the defects

Your replacement templates fixed seven of the eight defect classes that drove our June tutorial findings. Each figure below is the count in templates against the same search in tutorial-apps as a control:

Class from the June reporttemplatestutorial-apps control
commitment: 'processed' at provider level07
Credential logging05
Plaintext auth-token persistence019 store calls
Constant name contradicting its value03
await on a synchronous React setter05
skipPreflight: true01
Unawaited wallet calls0 of 31 sign sites7 direct authorize sites
Placeholder identity without a marker212

The seventh row is the most interesting: templates contains zero direct authorize( call sites, because the handshake moved inside a library. That does not catch the defect class, it removes the opportunity for it. Only the placeholder identity survived, in the one native-Kotlin template, where no library sits between the author and the identity struct (B101).

Also verified correct and worth recording:



Appendix - Verification

Every factual claim here is reproducible from public sources. No account, credential or non-public surface is required.

Attribution and dating method. For any defective string, the commit that introduced it:

git clone https://github.com/solana-mobile/solana-mobile-docs.git
cd solana-mobile-docs
git log -S'<exact defective string>' --format='%h %ad %s' --date=iso -- <path>

Note for anyone reproducing this on Windows under Git Bash: arguments beginning with / are rewritten by the shell layer and git log -S returns an empty result that looks like "no history". Export MSYS_NO_PATHCONV=1 first.

The assertion count:

git clone https://github.com/solana-mobile/mobile-wallet-adapter.git
cd mobile-wallet-adapter
grep -rnE '(^|[^A-Za-z0-9_.])assert[ (]'   android/walletlib/src/main android/clientlib/src/main --include=*.java | wc -l

Returns 47 at current head; returned 42 at the 8 June 2026 state. The entire increase is one new file, NostrRelayScenario.java, at lines 170, 192, 202, 220 and 228.

The registry consumption claim:

grep -rilE 'mobile-wallet-adapter-registry|wallet-registry|wallet-schema' \
  <all repos, all docs> --exclude-dir=.git | grep -v 'mobile-wallet-adapter-registry/'

Returns one file, and it is our own mirror report - no consumer. Controls, same flags, as matching-file counts: the pattern inside the registry repository returns 5; the sibling repository name mobile-wallet-adapter across the same roots, same exclusion, returns 275.

Snapshot provenance. Documentation fetched 10 September 2026 from the published sitemap - 72 pages, zero failures, cross-checked against llms.txt, which agrees at 72. Repositories cloned 10 September 2026 from github.com/solana-mobile/. The 8 June 2026 and 18 August 2026 comparison snapshots are preserved unmodified. Seven of the nine repositories were cloned at depth one, which is why findings in them are dated no more precisely than present at head.