Inside D'CENT's App Wallet: a Website and a Blind Signer
is known for its hardware wallets. Its phone app also carries a software “App Wallet”, and D’CENT has warned users of abnormal transfers limited to that App Wallet. The shipped app explains the shape of that warning: the App Wallet’s screen is a website, and the code that signs your transactions approves any data the website sends it once you enter a PIN. Nothing on the phone shows you where the money is going. Its optional social backup sends the recovery phrase to Wepin, a service D’CENT’s maker runs itself, which holds the key that opens it.
The App Wallet's screen is a website, and its signer approves whatever hash that website sends it once a PIN is entered. Hardware users with a screen are insulated; a hardware recovery phrase imported into the App Wallet is not. The opt-in social backup sends the recovery phrase to Wepin, the vendor's own service, which holds the key that opens it.
14 findings: 3 high, 4 medium, 5 low.
Open the full report- Version
- Android 9.2.1 (versionCode 1638, com.kr.iotrust.dcent.wallet)
- Current
- Google Play listed 10.0.0 (updated 27 Aug 2026) at the time of writing; that build is not covered by this audit
- Verified
- Google Play build; v3 signature and Play source-stamp verified
- Measured
- 2026-09-16
- Chains
- Multi-chain, including the XRP Ledger and Xahau
- Custody
- Self-custody by default, with a hardware option. The opt-in Wepin social mode uploads the whole recovery phrase to Wepin, a service IoTrust itself operates, encrypted to a key held on that side: the vendor can recover the seed.
- Signing
- The App Wallet screen is a remote web page. Its software signer approves any hash it is sent after a PIN, with no native display of the transaction.
The subject
The subject is the Android build com.kr.iotrust.dcent.wallet version 9.2.1 (versionCode 1638), by IoTrust. It is the genuine Google Play build: its signature and Play source-stamp both verify, with a signer certificate whose SHA-256 begins f429d184. The user interface is a JavaScript bundle inside the app; the security-relevant native pieces are compiled Java classes beside it. Both are readable by anyone who downloads the same file.
This is a reading of the app only. No server was contacted, and the app cannot say what caused the transfers D’CENT reported. What it can say is what the design allows, and that is enough to explain who is at risk and why.
The App Wallet screen is a website
Opening the App Wallet loads a web page into a full-screen browser view. The address is a setting whose built-in default is https://v5wallet.dcentwallet.com/. The app polls that site’s version.json and clears its cache when the version changes. The screen you tap on is served from D’CENT’s web infrastructure, not baked into the signed app.
That web page talks to the app through a message channel. The app parses whatever JSON the page posts and passes it straight to a dispatcher. There is no check on where the message came from: no comparison of the page’s address, no allow-list. The dispatcher reads one field,request_to, and routes the request to one of five targets, one of which is the software signer.
Who can change that page
If the screen is a website, the next question is who gets to decide which website. The code gives a narrower answer than it first looks, and it has three parts.
The address is not pushed by a server. It is a stored setting. Its built-in value is compiled into the app and re-applied whenever the wallet version changes, and it can be typed in by hand on a developer and test screen inside the app. That screen also carries the developer-mode switch and the certificate-trust toggle, both of which default to off. So nothing arriving over the network repoints the wallet on its own; changing it means changing the app’s own stored value.
Standing in the middle of the connection is not free, but nothing in the app prevents it. Without certificate pinning the app trusts whatever certificate authorities the phone trusts, so an attacker needs a certificate for that host the device will accept: a rogue or compromised authority, or a root certificate installed on the phone by an employer or by a user who was talked into installing one. That is a higher bar than sharing a coffee-shop network. It is also a far lower bar than a pinned app, where a certificate the phone trusts still would not be enough.
The cheapest path needs none of that. The in-app dApp browser already loads any address you give it with the signer bridge attached. An attacker who can put a link in front of someone does not need to proxy the wallet page, compromise a host, or forge anything. That door is two sections down.
The signer approves any hash it is sent
A request_to: 'soft' message carrying a get_sign command reaches the software wallet. It checks that each item has a key path, a key spec and an unsignedHash, then shows a bottom sheet. That sheet renders only a PIN or one-time-code keypad. It is given no destination, no amount, no token: nothing about the transaction. On success it signs every hash it was given with the software key.
Whatever the page renders. The values are an illustration; the page could show any amount and any address.
{ "header": { "request_to": "soft" },
"body": { "command": "get_sign",
"parameter": { "params": [{
"keyPath": "m/44'/144'/0'/0/0",
"keySpec": { … },
"unsignedHash": "5A3F…C91E" }] } } }No destination. No amount. No token. The sheet gets a hash and a keypad.
This is the heart of it. The thing that signs does not know, and does not show, what it is signing. Whatever you believe you are approving comes from the same web page that asked for the signature. On a hardware wallet the device screen is the source of truth; for the software key there is no such screen.
A second door: the in-app dApp browser
The app also has a browser for connecting to decentralized apps. It is built on the same bridge, so it opens the same signer to any site. In the shipped build:
- It accepts every origin. The browser’s allowed-origin list is a single wildcard (
originWhitelist: ['*']), so no site is excluded. - It allows mixed content. Its mixed-content mode is
always: a secure page may load insecure scripts and frames, the setting a hardened browser refuses. - Its safety check is empty. The function that decides whether a URL may load,
isSafeUrl, returns true for any non-empty string. - The bridge is on for every page. Entering the dApp view switches it on, and every message that is not one of the provider engine’s own status messages goes straight to the dispatcher, with no allow-list of commands or targets.
- So any page it loads can post the same
soft/get_signrequest. The PIN prompt is again the only thing in the way. - The provider engine is remote and unsigned. The script injected into every dApp page is fetched at run time from
https://assets.dcentwallet.com/engine, downloaded as plain text and run with no signature, hash or integrity check. The list of extra scripts to load is itself fetched from the same host, so the code that runs inside the browser is managed remotely.
The bridge is not only a signer. Its native target carries device commands over the same unguarded channel, so a page can also ask the app to read the clipboard (get_clip_board_text), open the camera QR scanner, start speech recognition through the microphone, and read or change app settings. A wallet’s clipboard is where addresses, and too often recovery phrases, get pasted.
Taken together, this is a social-engineering route, meaning the attacker’s work is talking a person into opening a link, not breaking into anything. A page reached through that browser, one dressed up as a token sale or an airdrop claim for instance, can ask the signer for a signature as soon as it loads, and what the user sees next is the same PIN keypad the app shows for every genuine transaction. Nothing on that keypad says which page asked, or what it asked for.
The main wallet WebView adds a hardening gap of its own: it enables file access and universal access from file URLs, settings a wallet WebView normally disables. Nothing in this analysis exercises it, so it is a gap, not a demonstrated vector.
The seed at rest, and the other doors
To be fair to the design: the default-mode seed is stored well. It sits in the Android Keystore, the phone’s hardware-backed key store, and the app opts out of Android’s backup. The PIN is not part of that encryption; it only gates the prompt on screen. Nor is the innermost layer a lock: its key is derived from an install id the app generates itself, a random identifier plus a timestamp, and stores on the phone, and it uses a fixed all-zero initialization value. The PIN verifier runs bcrypt at a low cost. What actually protects the seed is the Keystore wrapping around all of that. The table lists the pieces.
| Where the seed lives | Android Keystore, via react-native-keychain; the app opts out of Android backup |
| What the PIN does | gates the on-screen prompt only; it is not part of the encryption key |
| PIN verifier | bcrypt at a low cost (older records: SHA-256 with a fixed salt on iOS) |
| PIN shape | exactly six digits; a short lockout after five wrong tries, an hour after eleven |
| Inner seed layer | AES-128-CBC under a key derived from the install id, with an all-zero IV |
| Install id | made by the app (a random identifier plus a timestamp) and stored on the phone; not a user secret |
| Cloud backup (opt-in) | argon2id, then AES-256-GCM; no minimum backup-password length |
| Social backup (opt-in) | the whole phrase, encrypted to a key held at the vendor’s own Wepin service; the vendor can recover the seed |
Transport, tracking, and a developer bypass
No certificate pinning. The only pin-shaped strings in the app are the HTTP library’s own error text, and there is no network-security configuration. The wallet page and the engine are trusted on the network’s word alone.
Cleartext HTTP is enabled for the whole app (usesCleartextTraffic is on), and the build does ship plain-HTTP endpoints: an RPC node at http://158.247.251.208:8545 and a block explorer at http://141.164.63.111. Both belong to the chain entry the app names DSRV Testnet and marks isTestnet: true, so no real funds ride on them. What the flag does mean is that a custom node a user adds by hand may be http://, and that traffic would be readable and alterable on the path.
Three analytics and attribution kits ship in the app: AppsFlyer, Firebase Analytics (Google’s measurement library) and Amplitude, alongside Sentry crash reporting and Pushwoosh push notifications. The manifest requests the advertising id and the full set of AdServices permissions (attribution, ad id, custom audience, topics), and the microphone permission is live, used by a speech-recognition module the bridge exposes. That is heavier tracking than most key-holding apps.
A developer TLS-trust path ships in the production build. D’CENT patched the WebView library so that a certificate error can be waved through. It is tightly scoped: developer mode and a toggle must both be on, the error must be an untrusted-certificate error, the certificate must chain to a developer root fetched from D’CENT, and the host must be the configured dev server or a private address. That makes it dev tooling left in, not a general bypass, but it is present.
Who is insulated
The app offers four ways to hold a key, and the design above lands on them very differently. What matters is whether anything the phone cannot rewrite shows you what you are about to sign.
- Key lives
- on the device
- Confirms on
- the device’s own screen
- Key lives
- on the card
- Confirms on
- the phone’s web page
- Key lives
- on the phone
- Confirms on
- the phone’s web page
- Key lives
- on the phone, plus an encrypted copy at the vendor’s own Wepin service
- Confirms on
- the phone’s web page
A hardware recovery phrase imported into the App Wallet becomes a software key: it is signed on the phone with no device at all, which is exactly the group D’CENT told to move their funds.
Check it yourself
Every claim above is reproducible from the same public APK. Unzip the JavaScript bundle from assets/index.android.bundle (called B below) and run the searches. Each is a fixed-string search; a zero result would contradict the matching claim.
| Claim | Search | Expect |
|---|---|---|
| The wallet screen is a remote website | grep -aoF 'https://v5wallet.dcentwallet.com/' B | the compiled default URL |
| The bridge accepts five signer targets | grep -aoF 'isValidProcessorType' B | the list is ['card','device','native','soft','social'] |
| The soft signer takes a caller-supplied hash | grep -aoF 'unsignedHash' B ; grep -aoF 'signDataWithJS' B | the field it signs, and the call that signs it |
| Only a PIN sheet stands in front of it | grep -aoF 'AuthBottomSheetService show' B | the sheet is passed no transaction data |
| The dApp browser trusts any origin | grep -aoF 'originWhitelist' B ; grep -aoF 'isSafeUrl' B | ['*']; isSafeUrl returns true for any non-empty string |
| Its provider engine is remote and unsigned | grep -aoF 'https://assets.dcentwallet.com/engine' B | fetched as text, no signature or hash check |
| No certificate pinning | unzip -p apk classes4.dex | grep -aoE 'sha256/[A-Za-z0-9+/]{43}=' | zero pin-shaped values in any DEX |
| Seed at rest is Keystore-wrapped | grep -aoF 'getGenericPassword' B ; grep -aoF 'keyDeriveKEK' B | react-native-keychain plus a device-id KDF |
| Wepin mode uploads the recovery phrase | grep -aoF 'v2-api.wepin.io' B ; grep -aoF '/auth/publickey' B | to a provider-supplied public key |
| A cleartext RPC node, on a test network | grep -aoF 'http://158.247.251.208:8545' B ; grep -aoF 'DSRV Testnet' B | one hit each; that chain entry carries isTestnet: true |
| The bridge reaches the clipboard | grep -aoF 'get_clip_board_text' B | a native command a bridged page can call |
| Amplitude, the third analytics kit | grep -aoF '[Amplitude] Initialized successfully' B | AppsFlyer and Firebase are in the DEX class names |
| A developer TLS-trust path ships | unzip -p apk classes4.dex | grep -aoF 'maybeProceedDevSslTrust' | called from the WebView SSL-error handler |
| The wallet address is a stored setting | grep -aoF 'setWalletWebUrl' B | written from the compiled value or by hand, never from a message |
| The social mode is a registered client of the vendor’s own Wepin service | grep -aoF 'WEPIN_DOMAIN_ANDROID' B ; grep -aoF 'delivery.wepin.io' B | app id and keys per environment, and links to that service's own terms and privacy pages, which name IoTrust Co., Ltd. as its operator |
What this proves, and what it does not
The architecture is proven from the code: the App Wallet’s screen is a remote website; the bridge accepts commands from whatever page is loaded, with no origin check; the software signer signs any hash after a PIN, with no native display of the transaction; the dApp browser exposes the same signer to arbitrary sites; the provider engine is remote and unsigned; and nothing is pinned. A compromised or spoofed wallet page, a malicious dApp, or a tampered engine script could each obtain a signature the user cannot inspect on the phone.
If one thing here is the weakest point, it is not any of the doors: it is that the signer has no display of its own. Pinning, an origin check and a signed engine would each close a way in, and each is worth doing. But as long as the thing holding the key renders nothing and signs a number it was sent, entering a PIN proves only that a person was present. It never proves the person agreed to the transaction that got signed. Showing you what you are about to approve, on something the phone cannot rewrite, is the property a hardware wallet exists to provide, and the one the App Wallet lacks.
What the app cannot prove is what actually moved the funds D’CENT reported. That needs D’CENT’s own disclosure and on-chain forensics; no honest reading of the client can assert a server compromise as fact. What the design does explain is the shape of D’CENT’s own advice.
That advice is now on the record. ’s notice of 16 September 2026 says that “abnormal asset transfer cases have been identified involving the DCENT App Wallet”, that no impact “originating from DCENT Hardware Wallets themselves” has been confirmed, and that anyone sharing a recovery phrase between the App Wallet and a hardware wallet must act “regardless of where that recovery phrase was originally generated”. It tells App Wallet users to update the app before moving anything and not to transact from the App Wallet until then, and it warns that impersonation and phishing attempts are under way. The version read here is 9.2.1, obtained from a mirror on 7 September; Google Play’s listing shows 10.0.0, updated on 27 August, and whether that build changes any of the properties above is not covered by this analysis.
The transfers themselves are on the ledger, and we have read them to the second: 1,552 wallets emptied of 2,009,321 XRP on 15 September between 16:29 and 18:34 UTC, in two batches from one script, with the twelve richest wallets moved by hand in between. That account, including where the money is now, is a separate piece: The D’CENT Drain, Minute by Minute.
What would fix it
Each of these would break the chain on its own:
- A native confirmation for software signing that renders the destination and amount from the decoded transaction, not from the web page, so what the user approves is what the phone shows.
- An origin check on the bridge, so the signing targets are reachable only from the wallet’s own page and never from a dApp.
- A signed provider engine, verified before it runs.
- Certificate pinning for the wallet and engine hosts.
The social backup needs a fix of its own: wrap the phrase to a key only the user holds, or split it so that no single party, the vendor included, can read it.
Until then, the practical takeaway is ’s own: never reuse a hardware-wallet recovery phrase in the software App Wallet. For more wallet teardowns, see other insights.
The social backup sends the recovery phrase to the vendor
One optional mode changes who can read the seed. The Wepin “social” wallet lets a user hold a wallet behind a social sign-in and a short PIN instead of keeping a recovery phrase themselves. To do that, the app fetches a public key from a service at
wepin.io, wraps a freshly generated key to it, encrypts the whole recovery phrase under that key, and uploads the result. The app never holds the matching private key and contains no key-splitting logic, so the key that opens the uploaded phrase is held on the service’s side. The PIN does not lock the phrase: the app hashes it and uploads the hash under the same key, so it is a check on the service’s side, not a lock on the phone’s.That service is not a third party. Wepin’s terms of service, the page the app itself links to, name its operator as “IoTrust Co., Ltd.”, the company that makes . So in this mode the vendor can recover the seed. The same terms say the company “cannot access the user’s original private key information”; from the phone’s side, nothing but a key that service supplies stands between the uploaded phrase and whoever holds that key.
Wepin runs as a separate service with its own addresses and terms, and the app is a registered client of it: the build carries an application id, an OAuth redirect address and an API key for each of its three environments, including the live one. (Those key values are not reproduced here.) None of the service’s code is compiled into the app; the integration is web requests to its interface.
Two details sit oddly beside that. The call that creates the wallet in production labels it
dcent-wallet-test. And the same configuration block carries a second, development wallet address pointing at the phone itself, which is reached only with developer mode on. Neither is dangerous by itself. Together they say development settings shipped to users.