| Audited | trustwallet/wallet-core at commit d40d24a63d92619167903369308bf0e2f7eb3a59 |
|---|---|
| Date | 11 October 2026 |
| How it ran | cloud session, full audit, Standard review |
| Verdict after review | Pass with notes (rule: Fail if a High finding remains after review, otherwise Pass with notes) |
Each finding keeps the number it has in the audit report. The rating shown first is the one after review; the first automated rating is listed with it. 29 findings were first rated Medium or High; 6 of them are Medium or High after review. Text marked "From the report" is quoted from the audit report; fixes are suggestions and were not tested. Code locations link to the file and line at the audited commit.
Medium after review (6)
src/Cbor.cpp:309 (also line 249)From the report
uint32_t elemLen = nextElem.getTotalLen();
...
uint32_t dataLen = skipClone(typeDesc.byteCount).getTotalLen();
getTotalLen and getCompoundLength recurse once per nesting level, and isValid and getCompoundElements follow the same pattern. A few kilobytes of input such as 0x81 0x81 0x81 … or 0xC0 0xC0 … makes one stack frame per byte. That input is reached through Cardano address parsing after base58 decoding, and through Signer::plan validation of auxiliary data. A stack overflow cannot be caught by the surrounding catch, so it crashes the host app, especially on small-stack worker threads.
Add a nesting-depth limit (for example 64) to every recursive walker, or make the length walk iterative. Reject input that exceeds it.
Review: Defect real: Decode::getTotalLen (Cbor.cpp:225-253) calls getCompoundLength, which calls getTotalLen on each child, and the tag case at :249 calls itself directly. There is no depth counter (grep for depth/nesting/MAX finds nothing), so 0x81 0x81 ... recurses once per input byte. Reachable through the public API: AddressV2::parseAndCheck (Cardano/AddressV2.cpp:20) does Base58::decode then Cbor::Decode(...).getArrayElements(), and isValid is the path behind TWAnyAddressIsValid for Cardano. Signer.cpp:71 also runs isValid() on caller-supplied auxiliary data. It needs a base58 string of a few KB on a small-stack thread, or tens of KB on an 8 MB stack, so it is a remote-input crash with no key or signing impact. Fair MEDIUM. Upstream: 2 searches, none found (the first returned only open third-party audit issue #4706, which does not cover CBOR depth).
Upstream: No exact upstream report; related items: #4707 (PR, merged), #4706 (issue, open).
rust/tw_evm/src/abi/decode.rs:199From the report
for elem_idx in 0..len {
let res = decode_param(kind, tail, new_offset)...;
tokens.push(res.token);
len comes from the calldata (up to 2^32−1) and is not checked against the remaining data. For element types that consume no bytes (an empty tuple, tuple[]), a 32-byte input pushes billions of tokens. For bytes[] or string[], every element offset can point at the same large blob, which is copied once per element. A 256 KB calldata then allocates about a gigabyte. This is reachable from the public ABI decode functions, and with panic = "abort" the result is a host-process abort or OOM kill.
Before the loop, require len × minimum element size ≤ remaining bytes. Reject zero-sized element types, and keep a total decoded-bytes and token budget per call, as the encoder already does with its 4096 cap.
Review: Defect real: ParamType::Array reads len as up to 2^32-1 and loops for elem_idx in 0..len (decode.rs:199) with no check against remaining data. The report's headline claim, "a 256 KB calldata allocates about a gigabyte", is correct only as amplification: each dynamic element still consumes a 32-byte head word (new_offset = offset + WORD_LEN), so the loop is bounded by data.len()/32. With every offset pointing to one blob, take_bytes copies roughly D^2/128 bytes, which is about 0.5 GB at 256 KB and about 128 MB at 128 KB. The zero-size path (tuple[] with an empty tuple) needs a hostile ABI definition, and I found no validation that rejects empty tuples, so it is real but needs the ABI to be attacker-influenced. The public entry points (tw_ethereum_abi_decode_params and decode_value) take the ABI from the app and the calldata from outside. Allocation failure aborts regardless of panic = "abort". Fair MEDIUM (DoS, input-size dependent). Upstream: 1 search ("abi decode array length memory"): 3 results, none about this (#4784 Cardano CIP-20, #3862 abi.encodePacked, #2293 Nervos).
Upstream: No exact upstream report; related items: #4717 (PR, merged).
From the report
let len = *VarInt::decode(r)?;
let mut buf = vec![0; len as usize];
r.read_exact(&mut buf)?;
About 15 bytes of transaction hex with a memo length of 2^40 make the library try a terabyte allocation. This is reachable from the transaction-hash utility. Under panic = "abort" the host app is killed.
Read through r.take(len) and verify the byte count, or cap len at the protocol maximum before allocating.
Review: Defect real: decode_var_slice does let len = *VarInt::decode(r)?; vec![0; len as usize] (decode.rs:12-13) before read_exact. The memo is decoded at transaction/mod.rs:146, and PactusTransactionUtil::calc_tx_hash_impl (modules/transaction_util.rs:23-25) hex-decodes and deserializes caller input, which TWTransactionUtilCalcTxHash exposes. A memo length of 2^40 asks for a 1 TiB allocation; failure is an unconditional handle_alloc_error abort, so panic = "abort" is not even needed. The weakness is real but limited: it is a hash utility on one niche coin, with a DoS-only effect. Fair MEDIUM.
Upstream: No exact upstream report; related items: #4592 (PR, merged).
tools/install-sys-dependencies-linux:17From the report
wget -O "$LCOV_DEB" http://mirrors.kernel.org/ubuntu/pool/universe/l/lcov/lcov_1.15-1_all.deb
sudo apt-get install "$LCOV_DEB"
A local .deb installed this way has no signature verification. Anyone positioned on the network path, or a compromised mirror, can substitute the package. Its maintainer scripts then run as root on developer machines and on every Linux CI runner that builds the wallet library.
Use HTTPS and verify a pinned SHA-256 before installing, or use the distribution package or a checksummed source archive.
Review: A local .deb has no apt signature check, so an on-path attacker gets root during install. It is called from linux-ci, flutter-ci, codegen-v2 and the sonarcloud workflows, so it also runs on developer Ubuntu boxes. It is not library code and does not touch shipped artifacts directly. Exploitation needs a network position on a plain-HTTP fetch, and GitHub-hosted runners are ephemeral, so HIGH is too high. Fair MEDIUM, a reasonable supply-chain hygiene issue.
Upstream: No exact upstream report; related items: #4854 (PR, merged).
src/Keystore/Account.cpp:31 (also lines 34–35 and 43)From the report
if (json[CodingKeys::derivationPath].is_object()) {
...
coin = TWCoinType(uint32_t(derivationPath.indices[1].value));
json is const, so operator[] on a missing key is undefined behaviour. An account with neither coin nor a two-level path indexes an empty vector. A malformed backup file can crash the host app during import, and a crash cannot be caught by the import's catch (...).
Check contains() before every read, and reject accounts without a coin or a path of sufficient depth.
Review: Account.cpp:28-43 reads json[CodingKeys::derivationPath] on a const json without checking the key exists (UB, an assert only in debug). If coin is absent, derivationPath.indices[1].value indexes an empty vector (a null-pointer read, which crashes). An activeAccounts entry such as {"address":"x"} is enough. It is reached from keystore import (TWStoredKeyImportJSON, TWStoredKeyLoad) through StoredKey::loadJson, where accounts.emplace_back(accountJSON) has no per-account try (StoredKey.cpp:459-461). The enclosing catch (...) cannot catch a segfault. It is a deterministic crash on every platform from a tiny crafted file, which is worse than #49 on ARM.
Upstream: No matching report among the project's newest 1,000 issues and pull requests (11 October 2026).
src/Keystore/ScryptParameters.cpp:87From the report
if ((r > std::numeric_limits<uint32_t>::max() / 128 / p) ||
(n > std::numeric_limits<uint32_t>::max() / 128 / r)) {
validate() runs on JSON from the imported keystore file. With "p": 0 (or "r": 0) it divides by zero. On x86 hosts (desktop, server, simulators) this raises SIGFPE, and catch (...) cannot stop it. On every platform it is undefined behaviour. A single malicious "backup file" crashes the importing app.
Reject p == 0 and r == 0 before any division, and set sane bounds on n, dklen and the PBKDF2 iteration count.
Review: Defect real and trivially triggered: ScryptParameters(json) (ScryptParameters.cpp:114-139) assigns p and r from the JSON and calls validate(). validate() at :87-88 evaluates max / 128 / p and max / 128 / r with no zero check (the earlier r*p >= 1<<30 check does not catch 0). It is reached from keystore import through the C API (TWStoredKeyImportJSON, TWStoredKeyLoad). Unsigned division by zero traps on x86 (desktop/server/emulator) and in wasm. From general ISA knowledge (not checked here), AArch64 returns 0, so most phones would fall into the "overflow" error instead of crashing. The C++ catch (...) cannot stop a hardware trap, and the compiler may assume p != 0 since this is UB. The crash needs a user to import a crafted file. Fair MEDIUM.
Upstream: Already reported upstream: #4897 (PR, open), #4898 (PR, closed unmerged).
Low after review (46)
rust/tw_keypair/src/ed25519/secret.rs:98From the report
let k = Scalar::from_hash(h);
let s = k * self.key + r;
Ok(Signature { R, s })
Signing is assembled in-house from curve primitives, a port of an old ed25519-dalek release with a pluggable hasher. The signing method also takes the public key as a separate argument. Hand-maintained signature code is the most expensive place to carry a subtle bug, and a past advisory already concerned this exact function. No defect was demonstrated.
Use a maintained signature crate (for example ed25519-dalek 2.x) for the standard SHA-512 variant. Keep custom code only for the non-standard hashers, isolated in one small module. Add the RFC 8032 and Wycheproof vectors to CI, and make sure the variant that takes a caller-chosen public key cannot be reached from outside the crate.
rust/tw_keypair/src/ed25519/secret.rs:60From the report
/// TODO make sure if this is the right way to create an extended secret key (extsk).
pub(crate) fn with_extended_secret(secret: H256, extension: H256) -> Self {
let key = Scalar::from_bytes_mod_order(secret.take());
The code's own comment says the reduction changes the secret, and that it is not certain this is correct. Key-material derivation that is knowingly unverified is a latent risk.
Resolve the TODO. Use a vetted BIP32-Ed25519 implementation, or add reference test vectors that prove the signing scalar and public key match the reference.
From the report
fn diffie_hellman(private: &SigningKey, public: &VerifyingKey) -> AffinePoint {
let public_point = ProjectivePoint::from(*public.as_affine());
(public_point * secret_scalar).to_affine()
The key agreement does the scalar multiplication by hand and hashes the compressed point with its tag byte, instead of using the library's shared-secret API. This is hard to audit and easy to mismatch with peers.
Build on k256::ecdh::diffie_hellman. If the legacy output must be kept, layer it on top and label it as a compatibility-only KDF, with test vectors.
rust/chains/tw_polkadot/src/entry.rs:44From the report
let genesis_hash = input.genesis_hash.as_ref().try_into().unwrap_or_default();
let current_hash = input.block_hash.as_ref().try_into().unwrap_or_default();
A genesis or block hash of the wrong length becomes an all-zero hash, and the transaction is signed over it. The caller gets a valid-looking signature that the network rejects, or one bound to the wrong chain context, with no error.
Return an invalid-value error when the length is not 32. Allow an empty block hash only where the era makes it legitimate.
Review: try_into().unwrap_or_default() at tw_polkadot/entry.rs:44-45 and tw_polymesh/entry.rs:51-52 turns a wrong-length genesis or block hash into 32 zero bytes (verified). The defect is real, but no chain has an all-zero genesis hash, so the signature cannot be replayed anywhere. It is a caller-error case that the chain rejects, and a bad-input hygiene issue rather than a vulnerability. Both are shipped library code.
rust/chains/tw_polymesh/src/entry.rs:51From the report
let genesis_hash = input.genesis_hash.as_ref().try_into().unwrap_or_default();
let current_hash = input.block_hash.as_ref().try_into().unwrap_or_default();
This is the same defect as finding 4, in the Polymesh entry point.
As in finding 4.
Review: try_into().unwrap_or_default() at tw_polkadot/entry.rs:44-45 and tw_polymesh/entry.rs:51-52 turns a wrong-length genesis or block hash into 32 zero bytes (verified). The defect is real, but no chain has an all-zero genesis hash, so the signature cannot be replayed anywhere. It is a caller-error case that the chain rejects, and a bad-input hygiene issue rather than a vulnerability. Both are shipped library code.
From the report
blake2_b(&self.sign_bytes().unwrap_or_default(), 32).unwrap_or_default()
If encoding fails, the id becomes the hash of an empty buffer. That is the same plausible-looking constant for every failed transaction.
Return a Result from id() and propagate the error.
rust/tw_keypair/src/ffi/privkey.rs:111From the report
// Return an empty signature if an error occurs.
let sig = private.0.sign(message_to_sign, curve).unwrap_or_default();
CByteArray::from(sig)
A host that does not check the length can treat a failed signature as a success.
Return the existing status-carrying result type, or a null array, and document that callers must check it.
src/Encrypt.cpp:79From the report
const byte paddingSize = result[result.size() - 1];
if (paddingSize <= result.size()) {
const size_t unpaddedSize = result.size() - paddingSize;
A final padding byte of 0, or any value above 16, is accepted. The function then silently returns the wrong plaintext (untrimmed, or truncated by up to 255 bytes) instead of failing. The padding bytes themselves are never checked. Callers of TWAESDecryptCBC cannot tell corrupt or wrong-key ciphertext from good data.
Require 1 <= paddingSize <= 16 and check that every padding byte equals paddingSize, otherwise throw. Prefer an authenticated mode (AES-GCM, or encrypt-then-MAC) for new uses.
Review: Confirmed at Encrypt.cpp:79-84: padding byte 0 or greater than the buffer is accepted, and the padding bytes are not checked. The report's "truncated by up to 255 bytes" is bounded by the ciphertext length, and decryption is unauthenticated CBC anyway, so unpadding is not a validity check. Adding strict checking would create a padding oracle if callers surface the error, so this is not clearly a net security gain. Keystore decryption uses CTR with a MAC, not this function. It is public via TWAESDecryptCBC. I saw an open third-party issue (#4706, F-07) rating the same problem Low.
src/interface/TWStoredKey.cpp:246 (also lines 271 and 280; swift/Sources/KeyStore.swift:276)From the report
} catch (...) {
return nullptr;
}
At the C boundary, a wrong password, a corrupt file, an unsupported cipher and an unsupported KDF all become nullptr. The Swift wrapper turns every nil into Error.invalidPassword. A user with a damaged or unsupported keystore is told to retype a password that is in fact correct, and may give up on the funds.
Expose the failure cause, as an error code or a result type carrying the existing DecryptionError values. Map each cause to a distinct, understandable error in the Swift, Kotlin and TypeScript wrappers.
Review: TWStoredKey.cpp:246/271/280 have catch (...) { return nullptr; }, and Swift exportPrivateKey (KeyStore.swift:276) maps nil to Error.invalidPassword. All true. This is UX and diagnostics, not a security flaw, and fails closed.
src/Keystore/PBKDF2Parameters.cpp:38From the report
if (json.count(CodingKeys::iterations) != 0) {
...
iterations = json[CodingKeys::iterations];
A keystore with a missing or misspelt c field is decrypted with 262144 iterations. It then fails as "invalid password" instead of being reported as malformed. Similar silent defaults exist for a missing scrypt salt and for a missing coin (the Ethereum fallback).
Treat a missing iteration count as a malformed file, or as an explicitly flagged legacy case.
android/gradle.properties:20From the report
android.enableJetifier=true
Jetifier slows every build. It is only needed because of the pre-AndroidX test dependency android.arch.core:core-testing:1.1.1 (android/app/build.gradle:48).
Switch to androidx.arch.core:core-testing and set android.enableJetifier=false.
samples/android/build.gradle:14From the report
ext.kotlin_version = '1.3.50'
classpath 'com.android.tools.build:gradle:7.2.1'
Kotlin Gradle plugin 1.3.50 predates Gradle 7, which the sample uses (7.3.3). Toolchain mismatches at that boundary show up as confusing build errors for anyone copying the sample.
Move the sample to the same AGP, Gradle and Kotlin set as android/ (AGP 8.8.0, Gradle 8.10.2, Kotlin 2.1.0).
src/Keystore/EncryptionParameters.cpp:161 (also line 167)From the report
derivedKey.resize(scryptParams->defaultDesiredKeyLength);
scrypt(..., derivedKey.data(), scryptParams->defaultDesiredKeyLength);
The file's dklen is parsed, written back, and used when encrypting, but decryption always uses the default 32. The MAC key comes from the end of the derived key, so any keystore with dklen other than 32 is rejected as "invalid password" even with the right password.
Use the parsed dklen in decrypt(), and fall back to the default only when the field is absent. Validate that dklen is at least the cipher key length.
Review: decrypt() uses defaultDesiredKeyLength (EncryptionParameters.cpp:161, 167) while encryption uses scryptParams.desiredKeyLength (:105). A keystore with dklen other than 32 would be rejected as a wrong password. It is an interop limitation that fails closed, with no secrecy impact. Standard files use dklen 32.
type is silently read as a private keysrc/Keystore/StoredKey.cpp:427From the report
if (json.count(CodingKeys::SK::type) != 0 &&
json[CodingKeys::SK::type].get<std::string>() == TypeString::mnemonic) {
type = StoredKeyType::mnemonicPhrase;
} else {
type = StoredKeyType::privateKey;
The encrypted payload holds either a mnemonic or a raw key, and only type says which. Any unknown or misspelt value is coerced to "private key".
Accept exactly mnemonic and private-key, treat a missing value as legacy, and reject anything else.
src/interface/TWStoredKey.cpp:244From the report
const auto data = key->impl.payload.decrypt(passwordData);
return TWDataCreateWithBytes(data.data(), data.size());
TWStoredKeyDecryptPrivateKey on a mnemonic wallet returns the mnemonic's ASCII bytes as if they were a key. TWStoredKeyDecryptMnemonic on a private-key wallet returns raw bytes as a string. The Swift layer currently uses the first as a generic password check, so this is a contract ambiguity rather than a demonstrated bug.
Add kind-checked variants (or document that these functions are kind-agnostic), and keep a separate, explicit password-verification call.
src/Keystore/StoredKey.cpp:387From the report
auto data = payload.decrypt(password);
const auto dataHex = TW::hex(data);
On a mnemonic wallet without an encoded payload, this returns the hex of the mnemonic phrase as an "encoded private key".
Throw unless the wallet is a private-key wallet.
src/HDWallet.cpp:322From the report
hdnode_private_ckd(&node, path.change());
hdnode_private_ckd(&node, path.address());
return PrivateKey(Data(node.private_key, node.private_key + 32), curve);
deserialize() accepts public versions and leaves private_key zeroed. getPrivateKeyFromExtended then derives from a zero parent, ignores the derivation's return value, and returns a "private key". Anyone holding the xpub can compute it. Funds sent to its address are not safe.
Fail unless the version is a private version, and check every hdnode_private_ckd return value.
Review: The mechanics are right: deserialize leaves private_key zero for public versions (HDWallet.cpp:376-378), hdnode_private_ckd return values are ignored, and PrivateKey(...) accepts the result, so the output is derivable from public data. But getPrivateKeyFromExtended has no TW* wrapper (grep: only HDWallet.cpp/.h and tests/common/HDWallet/HDWalletTests.cpp), so it is not reachable through the C, Swift, Kotlin or TS APIs. The report's "funds sent to its address are not safe" only applies to a C++ embedder that calls this internal function with an xpub. It is internal API hardening.
src/Keystore/StoredKey.cpp:266From the report
if (getAccount(coin, address).has_value()) {
// address already present
return;
The "already present" check uses only coin and address. Accounts loaded from legacy files (empty public key and xpub, default path) are never enriched by a later, more complete addAccount.
Update missing or different fields on the existing account, or report that it already exists.
src/Keystore/StoredKey.cpp:209From the report
Account accountLval = account.value();
return fillAddressIfMissing(accountLval, wallet);
The stored account keeps the empty values, so they are re-derived on every call, and the in-memory and persisted states diverge.
Write the filled values back to the stored account through one setter.
src/Keystore/StoredKey.cpp:459From the report
for (auto& accountJSON : json[CodingKeys::SK::activeAccounts]) {
accounts.emplace_back(accountJSON);
}
addAccount keeps (coin, address) unique. The public loadJson appends without de-duplicating and without clearing existing accounts.
Route loading through the same guard and check the invariants after loading.
src/Cbor.cpp:400From the report
uint32_t len = getCompoundLength(countMultiplier);
...
if (!nextElem.isValid()) { return false; }
idx += nextElem.getTotalLen();
At every level the subtree is walked again, and WebAuthn.cpp calls dumpToString() on every key of an attacker-supplied attestation object. Moderately sized inputs cost far more CPU than their size suggests.
Validate in one pass that returns the consumed length under a shared step budget. Compare map keys by type and value instead of rendering them, and cap the number of entries.
Review: Mostly a duplicate of #21: isValid (Cbor.cpp:400) calls getCompoundLength at each nesting level, which is quadratic only for deeply nested input, and that input hits the #21 stack limit first. WebAuthn does call dumpToString() per key, but only in findIntKey (WebAuthn.cpp:98) over the COSE key map. findStringKey uses getString(). The cost is linear per key and called a handful of times.
src/Cbor.cpp:299 (compare line 402)From the report
uint32_t count = typeDesc.isIndefiniteValue ? 0 : (uint32_t)(typeDesc.value * countMultiplier);
For a declared count of 2^32, the length walker sees an empty array while the validator expects 2^32 elements. The two parts of the decoder disagree on what the structure contains.
Reject any declared count larger than the remaining bytes, and compute the count in one place.
src/Base58.h:26From the report
if (string.empty()) { return {}; }
...
Rust::CByteArrayResultWrapper res = Rust::decode_base58(string.c_str(), alphabet);
Address validation decodes the whole string before checking its size, and base58 decoding is quadratic. A pasted or deep-linked multi-megabyte "address" blocks the calling thread for a long time.
Reject strings longer than the largest legitimate payload (for example 128 characters, or about 512 for Cardano legacy addresses) before decoding.
Review: Base58.h:26 shows no length cap and the decode is quadratic (the Rust side was not read in full). A multi-MB string would block a thread, but apps normally bound address fields. It is hardening.
src/Waves/Transaction.cpp:97From the report
jsonTx["fee"] = fee;
jsonTx["amount"] = amount;
Values above 2^53 are possible and lose precision when a JavaScript host round-trips the JSON. The node then rejects the signature.
If the node API requires numbers, document that a BigInt-safe parser is needed. Otherwise also expose the amounts as decimal strings.
tools/download-dependencies:18 (also lines 29, 40, 51)From the report
curl -fSsOL https://github.com/google/googletest/releases/download/v$GTEST_VERSION/googletest-$GTEST_VERSION.tar.gz
googletest, libcheck, nlohmann/json and protobuf are fetched over HTTPS but never hash-checked. The cache check also trusts any file already on disk.
Store a SHA-256 next to each version in tools/dependencies-version and verify it before unpacking, including for cached files.
tools/sonarcloud-analysis:8From the report
curl -sSfL "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/${TARGET}" --output "${TARGET}"
unzip -q "${TARGET}"
The scanner runs with SONAR_TOKEN and GITHUB_TOKEN in the environment.
Pin and verify the archive's SHA-256, as the same workflow already does for another jar.
tools/install-wasm-dependencies:7From the report
git clone https://github.com/emscripten-core/emsdk.git
Check out a fixed emsdk commit and verify the boost zip's SHA-256.
tools/ios-release:58From the report
s.source = {
http: '${download_url}'
}
Add sha256: with the tarball's hash, as the Swift package release script already does.
.devcontainer/Dockerfile:58 (also gitpod.Dockerfile:17, .github/workflows/docker.yml:19, .github/workflows/linux-sampleapp-ci.yml:69)From the report
RUN curl -sSL --proto '=https' --proto-redir '=https' \
https://download.swift.org/swift-5.8-release/ubuntu2204/swift-5.8-RELEASE/swift-5.8-RELEASE-ubuntu22.04.tar.gz \
Verify a pinned checksum, as the same Dockerfile already does for rustup-init. Replace the deprecated apt-key add.
tools/kotlin-release:25 and tools/android-release:29From the report
./gradlew :wallet-core-kotlin:publishAllPublicationsToGitHubPackagesRepository -Pversion="$version" || echo "Warning: GitHub Packages publishing failed"
...
echo "Kotlin build uploaded"
A failed publish is turned into a warning, the script exits 0 and prints that the build was uploaded. A release can ship with no artifact and nobody notices.
Let the publish failure stop the script, then confirm that the artifact exists at the registry.
Review: Confirmed (kotlin-release:25, android-release:29: || echo "Warning...", then "build uploaded"). The failure is printed as a warning, and Maven-local publishing happens first. It is maintainer tooling with no user impact.
From the report
fs::create_dir(coin_tests_dir)?;
if tw_coin_type_tests_path.exists() {
println!("[SKIP] {tw_coin_type_tests_path:?} already exists");
create_dir fails when the directory exists, so the skip branch never runs. Scaffolding aborts part-way, after Coin.cpp and the enums have already been edited.
Use create_dir_all, as the sibling generators do.
Review: All confirmed and all developer tooling, not shipped. fs::create_dir on an existing directory fails (#35). Re-running adds duplicate enum lines because generate_coin_type_variant has no presence check (#36). The Ruby generator File.writes unconditionally (#37). tools/new-blockchain calls codegen/bin/... after pushd codegen, and codegen/codegen does not exist (#53). The suggested fix for #53 also needs checking because the Ruby script writes repo-relative paths from the current directory. These are developer-ergonomics bugs.
From the report
EntryGenerator::generate(coin)?;
TWCoinTypeGenerator::generate_coin_type_variant(coin)?;
BlockchainDispatcherGenerator::generate_new_blockchain_type_dispatching(coin)?;
The first step skips an existing chain, but the next steps insert enum values, includes, entries and case labels unconditionally. Re-running for an existing chain breaks the build with duplicates. The Rust workspace member insertion has the same problem.
Check whether each line is already present before inserting it, as the older Ruby editor does.
Review: All confirmed and all developer tooling, not shipped. fs::create_dir on an existing directory fails (#35). Re-running adds duplicate enum lines because generate_coin_type_variant has no presence check (#36). The Ruby generator File.writes unconditionally (#37). tools/new-blockchain calls codegen/bin/... after pushd codegen, and codegen/codegen does not exist (#53). The suggested fix for #53 also needs checking because the Ruby script writes repo-relative paths from the current directory. These are developer-ergonomics bugs.
codegen/lib/coin_skeleton_gen.rb:42From the report
FileUtils.mkdir_p folder
path = File.join(folder, fileName)
File.write(path, result)
Running the generator for an existing coin (its own usage text suggests ethereum) replaces hand-written Kotlin and Swift tests with skeletons. Uncommitted work is lost.
Skip or abort when the file exists unless an explicit overwrite flag is given, as coin_test_gen.rb does.
Review: All confirmed and all developer tooling, not shipped. fs::create_dir on an existing directory fails (#35). Re-running adds duplicate enum lines because generate_coin_type_variant has no presence check (#36). The Ruby generator File.writes unconditionally (#37). tools/new-blockchain calls codegen/bin/... after pushd codegen, and codegen/codegen does not exist (#53). The suggested fix for #53 also needs checking because the Ruby script writes repo-relative paths from the current directory. These are developer-ergonomics bugs.
From the report
let file_to_write = fs::File::create(write_to_path)?;
Safety depends on each of roughly 20 callers checking first, and several check only the parent directory.
Open with create_new(true) and make overwriting an explicit option.
.github/workflows/android-ci.yml:89From the report
key: ${{ runner.os }}-${{ runner.arch }}-internal-${{ hashFiles('tools/install-dependencies') }}-${{ hashFiles('tools/dependencies-version') }}
Three workflows include the system-dependency script in the cache key and five do not. A toolchain change can therefore leave stale caches in five pipelines.
Move the cache, install and codegen steps into one composite action or reusable workflow.
tools/kotlin-release:17 (also tools/android-release:21-26)From the report
echo "Publishing to Maven Local..."
When fixing finding 34, move the shared publish steps into tools/library so the fix is applied once.
rust/tw_memory/src/ffi/tw_string.rs:55 (reached from rust/wallet_core_rs/src/ffi/ethereum/abi.rs:92)From the report
TWString(CString::new(s).expect("CString::new(String) should never fail"))
The function-signature helper builds its output from a name parsed out of caller-supplied ABI JSON. A name containing \u0000 makes CString::new fail. Because the workspace builds with panic = "abort", the whole app terminates instead of receiving null.
Make the conversion fallible (return null, or strip or reject interior NULs), and reject NULs in ABI names.
Review: TWString::from(String) does CString::new(s).expect(...) (tw_string.rs:55). A \u0000 in a function name, plain string in function.rs signature(), reaches it via tw_ethereum_abi_get_function_signature and aborts. It is real, but it requires a hostile ABI JSON, a development-time input in nearly all uses.
src/Encrypt.cpp:42From the report
const auto resultSize = data.size() + padding;
for (idx = 0; idx < resultSize - blockSize; idx += blockSize) {
aes_cbc_encrypt(data.data() + idx, result.data() + idx, blockSize, iv.data(), &ctx);
With empty data and zero padding, resultSize is 0 and resultSize - blockSize underflows to a huge value. The loop then reads and writes past empty buffers, causing a crash or memory corruption. TWAESEncryptCBC passes caller input straight through.
Return early for empty input, and write the bound without unsigned subtraction (idx + blockSize < resultSize).
Review: Confirmed: paddingSize(0,16,Zero) is 0 (the unit test says so), so resultSize - blockSize underflows size_t (Encrypt.cpp:42-43) and the loop calls aes_cbc_encrypt on an empty vector (null pointers). That is a deterministic crash, not a corruption primitive, and it needs the unusual combination of empty plaintext and zero-padding mode from the caller. LOW.
src/Keystore/StoredKey.cpp:512 (also wasm/src/keystore/fs-storage.ts:27)From the report
auto stream = std::ofstream(path);
...
stream << jsonData;
The public store() truncates the existing wallet file before writing. A crash, kill or full disk mid-write leaves an empty or truncated keystore, which may be the only copy of the encrypted seed.
Always write to a temporary file in the same directory, fsync it, then rename it over the target. storeWithTemporaryFile already renames but does not fsync.
Review: store() truncates (StoredKey.cpp:507-519), true. But TWStoredKey.h:288-291 documents exactly this and tells callers to prefer TWStoredKeyStoreWithTemporaryFile, and the Swift keystore (KeyStore.swift:405) uses the temporary-file variant. Missing fsync is a valid hardening note. The wasm fs-storage.ts is the Node reference storage.
jni/cpp/Random.cpp:51From the report
cachedJVM->AttachCurrentThread(&env, nullptr);
...
env->CallVoidMethod(random, nextBytes, array);
None of the JNI calls is checked, and no exception check follows nextBytes. If any step fails, the zero-initialised array is copied out as "random" bytes for keys, salts or IVs. Threads attached here are never detached.
Check each result and ExceptionCheck(), and call std::terminate() on failure, as the /dev/urandom branch already does. Detach threads attached here.
Review: Random.cpp:51-68 does no checks on AttachCurrentThread, FindClass, NewObject, or exceptions after nextBytes. The zero-array outcome requires SecureRandom.nextBytes to throw, which is practically unheard of, and the null-class cases crash rather than yield weak keys. It is correct to harden (the /dev/urandom branch terminates on failure) but it is not a likely vulnerability.
wasm/src/keystore/fs-storage.ts:16From the report
getFilename(id): string {
return this.directory + id + ".json";
}
importWallet() stores a wallet under wallet.id taken from the imported JSON. An id such as ../../x reads, writes or deletes .json files outside the keystore directory.
Validate ids against a strict pattern (UUID or [A-Za-z0-9._-]+), or resolve the path and confirm that it stays inside the directory.
Review: The wallet id is concatenated into a path (fs-storage.ts:16-17), true, but importWallet(wallet) takes a JS object from the host app and the .json suffix is forced; this is a Node-only reference implementation, and the browser extension uses ExtensionStorage. delete(id, password) ignores the password (:40-41, extension-storage.ts delete), but the password is not an access control for code that already holds the storage handle, so the issue is a misleading signature. The extension-storage id-list read-modify-write race (:29-36) is real, but the wallet record is stored under its own key, so a lost index entry hides a wallet from loadAll() rather than deleting data.
From the report
return this.getWalletIds().then((ids) => {
if (ids.indexOf(id) === -1) { ids.push(id); }
return this.storage.set({ [id]: wallet, [this.walletIdsKey]: ids });
Two overlapping set() or delete() calls each read the old id list, and the last writer wins. A stored wallet can disappear from loadAll().
Serialise storage mutations through a promise queue, or derive the list from the stored keys.
Review: The wallet id is concatenated into a path (fs-storage.ts:16-17), true, but importWallet(wallet) takes a JS object from the host app and the .json suffix is forced; this is a Node-only reference implementation, and the browser extension uses ExtensionStorage. delete(id, password) ignores the password (:40-41, extension-storage.ts delete), but the password is not an access control for code that already holds the storage handle, so the issue is a misleading signature. The extension-storage id-list read-modify-write race (:29-36) is real, but the wallet record is stored under its own key, so a lost index entry hides a wallet from loadAll() rather than deleting data.
src/HDWallet.cpp:288From the report
hdnode_public_ckd(&node, path.change());
hdnode_public_ckd(&node, path.address());
hdnode_fill_public_key(&node);
On failure, for example with a hardened index or a private extended key whose public key is empty, the node is left unchanged. The account-level key is then returned as the derived child. The caller shows or uses the wrong receive address without any error.
Return std::nullopt when either derivation call returns 0.
Review: HDWallet.cpp:288-290 ignores hdnode_public_ckd results. hdnode_public_ckd_cp returns 0 for hardened indices (bip32.c, verified), so the node stays unchanged. The input has to be a hardened final index or a private xprv, which is caller error. It is reachable via TWHDWalletGetPublicKeyFromExtended. It is a missing error return, not a vulnerability.
From the report
// scan-and-flag posts the reminder → that triggers an issue_comment event
// → this job runs, finds the reminder, and fails until a sign-off exists.
if (!hasReminder) { core.info('No BC-risk reminder posted; nothing to verify.');
The reminder is posted with the default workflow token, and comments made with that token do not trigger new workflow runs. The cascade the gate relies on never happens. On PR open the verify job runs alongside the scan, finds no reminder, and reports success. A PR touching keystore, proto or registry paths can merge without sign-off if it is not pushed again.
Make the verify job depend on the scan job (needs:), or compute the flagged paths inside it, and fail whenever flags exist without a sign-off.
Review: #51's mechanism is right: the verify job has no needs and passes when no reminder exists yet (workflow lines 222-232), and comments made with GITHUB_TOKEN do not trigger new runs. The gap is narrow, because any later push, edit or comment re-runs verify and then fails correctly, and line 3 of the file says the check only blocks if configured as a required status check. #52 is by design ("reviewers judge", line 155). This is process tooling for backward-compatibility review, not a security gate.
From the report
const signoff = comments.find(c => {
const tokenMatch = c.body.match(tokenRegex);
if (!tokenMatch) return false;
Any account that can comment, including the PR author, can satisfy the gate with the token and 60 characters of text.
Require author_association of OWNER, MEMBER or COLLABORATOR, preferably not the PR author, or use CODEOWNERS review on these paths.
Review: #51's mechanism is right: the verify job has no needs and passes when no reminder exists yet (workflow lines 222-232), and comments made with GITHUB_TOKEN do not trigger new runs. The gap is narrow, because any later push, edit or comment re-runs verify and then fails correctly, and line 3 of the file says the check only blocks if configured as a required status check. #52 is by design ("reviewers judge", line 155). This is process tooling for backward-compatibility review, not a security gate.
tools/new-blockchain:10From the report
pushd codegen
codegen/bin/newcoin-mobile-tests $1
popd # codegen
After pushd codegen the path resolves to codegen/codegen/bin/…, which does not exist (searched; only codegen/bin/newcoin-mobile-tests exists). The mobile test skeletons are never generated. Because the script has no set -e or argument check, the failure is easy to miss.
Call bin/newcoin-mobile-tests "$1", add set -euo pipefail, and require an argument.
Review: All confirmed and all developer tooling, not shipped. fs::create_dir on an existing directory fails (#35). Re-running adds duplicate enum lines because generate_coin_type_variant has no presence check (#36). The Ruby generator File.writes unconditionally (#37). tools/new-blockchain calls codegen/bin/... after pushd codegen, and codegen/codegen does not exist (#53). The suggested fix for #53 also needs checking because the Ruby script writes repo-relative paths from the current directory. These are developer-ergonomics bugs.
Info after review (1)
From the report
delete(id: string, password: string): Promise<void> {
return fs.unlink(this.getFilename(id));
}
The API implies an authentication check, as the Swift keystore performs, but any caller can delete a wallet.
Decrypt the stored key with the password before deleting, or remove the parameter so the contract is honest.
Review: The wallet id is concatenated into a path (fs-storage.ts:16-17), true, but importWallet(wallet) takes a JS object from the host app and the .json suffix is forced; this is a Node-only reference implementation, and the browser extension uses ExtensionStorage. delete(id, password) ignores the password (:40-41, extension-storage.ts delete), but the password is not an access control for code that already holds the storage handle, so the issue is a misleading signature. The extension-storage id-list read-modify-write race (:29-36) is real, but the wallet record is stored under its own key, so a lost index entry hides a wallet from loadAll() rather than deleting data.