| Audited | Solana Mobile stack, third audit |
|---|---|
| Date | 10 September 2026 |
| How it ran | earlier 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)
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:
- B155 - on the dApp side of the new Nostr transport, the counterparty is unauthenticated: the relay can present itself as the wallet. The behaviour follows your specification, which mandates it while also naming the relay a potential adversary.
- B128, B129, B130 - the Kotlin transaction library emits structurally wrong message headers, cannot serialise any transaction requiring two or more signatures, and writes signatures in argument order rather than message order.
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:
| Repository | Head | Prior coverage |
|---|---|---|
solana-mobile-cli | 10 Sep 2026 | did not exist |
templates | 10 Sep 2026 | did not exist |
solana-mobile-skills | 4 Sep 2026 | did not exist |
web-shell | 21 Apr 2026 | never opened |
rpc-core | 3 Sep 2026 | named as uncovered in our own sign-off |
web3-core | 3 Sep 2026 | named as uncovered in our own sign-off |
mobile-wallet-adapter-registry | 3 Sep 2026 | named 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
| Severity | Count |
|---|---|
| Critical | 0 |
| High | 27 |
| Medium | 60 |
| Low | 54 |
| Advisory | 12 |
| Total | 153 |
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:
| Name | Specification | Java | TypeScript |
|---|---|---|---|
ERROR_NOT_CLONED | -5 | -5 | absent |
ERROR_TOO_MANY_PAYLOADS | -6 | -6 | -5 |
ERROR_CHAIN_NOT_SUPPORTED | -7 | -7, still named ERROR_CLUSTER_NOT_SUPPORTED | absent |
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.
- B91 - High. Keystore and key passwords passed to
keytoolin process arguments, with a shell enabled on Windows (signing.ts:206-209,:217). Any local process can read them. Your successor moved this to environment variables and annotates it "Passwords must never appear in argv" - the rule is known and the older published package is on the wrong side of it. - B92 - High. Command injection on Windows:
sdk.diris read from the cloned project'slocal.propertiesand interpolated into a shell-enabled argument. A checked-inlocal.propertiescontaining shell metacharacters executes onwebshell build. - B93 - High.
MIXED_CONTENT_ALWAYS_ALLOWin the shipped WebView template; the successor usesNEVER_ALLOW. - B94 - High.
intent:URLs parsed and launched unsanitised - no component clearing, no selector stripping, no flag removal, noCATEGORY_BROWSABLE, and a fallback URL launched with any scheme. Fixed in the successor.
The remaining findings by component
| Component | IDs | Count | Highest |
|---|---|---|---|
solana-mobile-cli | B80-B90 | 11 | Medium |
web-shell (superseded, published) | B91-B100 | 10 | High |
templates | B101-B114 | 14 | Medium |
solana-mobile-skills | B115-B118 | 4 | Medium |
rpc-core | B119-B127 | 9 | High |
web3-core | B128-B142 | 15 | High |
mobile-wallet-adapter-registry | B143-B151 | 9 | High |
| Nostr transport and MWA re-read | B155-B177 | 23 | High |
| Documentation | B152-B154, B178-B232 | 58 | High |
Selected items not detailed above:
- B80 (Medium).
device tuneapplies emulator-grade tweaks to physical handsets with no device-class guard, while the siblingemulator tunecorrectly refuses non-emulators. Two are security-relevant and survive reboot:locksettings set-disabled true, andam set-debug-app --persistent com.android.chrome, which makes Chrome honour an attacker-writable flag file.--yesapplies the whole table unprompted. - B119 (High).
sendAndConfirmTransactionreturns the send response because the confirmation runs inside.apply, so the failure raised when a transaction lands with an on-chain error is discarded. Callers receive a success-shaped response for a failed transaction. - B120 (High). The OkHttp driver performs a blocking call inside a
suspendfunction with no IO dispatcher - on Android's main dispatcher that throws; on any dispatcher it pins the thread. - B123, B128 (Medium/High). Two cases of a test suite that cannot fail: three tests claiming to cover error parsing use fixtures that are not valid JSON, so the parser under test is never exercised; and the message-builder suite asserts the incorrect header values.
- B164 (Medium). The auth-token HMAC is compared with
Arrays.equals, which short-circuits on the first differing byte.MessageDigest.isEqualis on the classpath and used nowhere in the repository - a controlled zero across the whole Android tree. - B166 (Medium).
sign_messagesthrows bareIllegalArgumentExceptionout of a dispatch path with no enclosing catch, so a request missing itsaddresseskey produces no protocol error at all; and the signing limit is applied to payload count only, while address count is checked against zero and nothing else, each entry being base64-decoded into a byte array. - B103, B104, B105, B115-B118 (Low/Medium). Your agent-instruction corpora -
solana-mobile-skillsand the skills vendored intotemplates- instruct an autonomous coding agent to place an API key in a committed config file that the build compiles into the binary, to run an unpinned remote package with the confirmation prompt suppressed at eight sites, to use--legacy-peer-deps, and torm -rfinsidenode_modules. The first contradictsAGENTS.md:25in the same repository, which says never to add secrets or real API tokens. Two of the three documentation links in the skills are dead, and the repository's own link checker is written to skip absolute URLs, so its CI cannot catch them.
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 report | templates | tutorial-apps control |
|---|---|---|
commitment: 'processed' at provider level | 0 | 7 |
| Credential logging | 0 | 5 |
| Plaintext auth-token persistence | 0 | 19 store calls |
| Constant name contradicting its value | 0 | 3 |
await on a synchronous React setter | 0 | 5 |
skipPreflight: true | 0 | 1 |
| Unawaited wallet calls | 0 of 31 sign sites | 7 direct authorize sites |
| Placeholder identity without a marker | 2 | 12 |
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:
- The Nostr key generator uses
SecureRandomwith a proper range-rejection retry, and the JavaScript side reaches the platform entropy source correctly. - Session encryption uses a fresh random 12-byte IV per message, never a counter, never reused; the sequence number is authenticated as associated data and reset only alongside a fresh key exchange; inbound sequence numbers are strictly monotonic.
- The auth-token HMAC key at rest is AES-GCM-wrapped by an Android Keystore key rather than stored in plain preferences, and a restored-but-missing keystore key causes the database to be deleted rather than reused.
- The new CLI keeps signing passwords in the child environment and never in process arguments, and its own tests assert this.
- The CLI verifies a pinned SHA-256 for every APK before writing it, downloads over HTTPS only, and binds its playground server to loopback with a body-size cap and no CORS headers.
- The CLI's template sync refuses symlinked artifact paths and submodules, and requires a clean tree.
- No custom
TrustManager,HostnameVerifierorSSLContextexists anywhere in the Android tree - platform TLS validation is intact. - Your version check warns rather than hard-blocking, which is the correct choice and the opposite of what the older publishing CLI does.
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.