Insights

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.

D'CENT Wallet
by IoTrust Co., Ltd. · Android 9.2.1 · measured 2026-09-16
Signs any hash after a PIN
Social backup: the vendor can recover the seed
62/ 100Moderate
History747162

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.
5
targets a web page can address through the bridge
0
origin checks before a message is dispatched
0
transaction fields shown to the PIN sheet
0
certificate pins in the app

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.

signature returned to the page that askedWallet pageloads a page fromdcentwallet.comAny dApp pagein the in-app browser+ remote engine, unsignedBridgeparses the JSONno origin checkDispatcherreads request_tocard device nativesoft socialsoft signerget_signtakes unsignedHashPIN sheeta keypad, nothing elseno destination,no amount
The path from a page to a signature. Both pages reach the same bridge and the same signer; the only stop between a message and a signature is a keypad.

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.

What the page shows
Send
100 XRP
to rDestination…
Confirm

Whatever the page renders. The values are an illustration; the page could show any amount and any address.

What the app receives
{ "header": { "request_to": "soft" },
  "body": { "command": "get_sign",
    "parameter": { "params": [{
      "keyPath":  "m/44'/144'/0'/0/0",
      "keySpec":  { … },
      "unsignedHash": "5A3F…C91E" }] } } }
Enter PIN

No destination. No amount. No token. The sheet gets a hash and a keypad.

The two sides of one signing request. The page decides what the user reads; the signer only ever sees the hash.

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_sign request. 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 livesAndroid Keystore, via react-native-keychain; the app opts out of Android backup
What the PIN doesgates the on-screen prompt only; it is not part of the encryption key
PIN verifierbcrypt at a low cost (older records: SHA-256 with a fixed salt on iOS)
PIN shapeexactly six digits; a short lockout after five wrong tries, an hour after eleven
Inner seed layerAES-128-CBC under a key derived from the install id, with an all-zero IV
Install idmade 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.

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 itdcent-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.

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.

Biometric wallet
Key lives
on the device
Confirms on
the device’s own screen
Insulated
D’CENT Card
Key lives
on the card
Confirms on
the phone’s web page
No screen of its own
App Wallet
Key lives
on the phone
Confirms on
the phone’s web page
Signs any hash after a PIN
Wepin social
Key lives
on the phone, plus an encrypted copy at the vendor’s own Wepin service
Confirms on
the phone’s web page
The vendor can recover the seed
Where the key lives and what confirms a transaction, per mode. Only the Biometric wallet has a screen the phone cannot rewrite.

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.

ClaimSearchExpect
The wallet screen is a remote websitegrep -aoF 'https://v5wallet.dcentwallet.com/' Bthe compiled default URL
The bridge accepts five signer targetsgrep -aoF 'isValidProcessorType' Bthe list is ['card','device','native','soft','social']
The soft signer takes a caller-supplied hashgrep -aoF 'unsignedHash' B ; grep -aoF 'signDataWithJS' Bthe field it signs, and the call that signs it
Only a PIN sheet stands in front of itgrep -aoF 'AuthBottomSheetService show' Bthe sheet is passed no transaction data
The dApp browser trusts any origingrep -aoF 'originWhitelist' B ; grep -aoF 'isSafeUrl' B['*']; isSafeUrl returns true for any non-empty string
Its provider engine is remote and unsignedgrep -aoF 'https://assets.dcentwallet.com/engine' Bfetched as text, no signature or hash check
No certificate pinningunzip -p apk classes4.dex | grep -aoE 'sha256/[A-Za-z0-9+/]{43}='zero pin-shaped values in any DEX
Seed at rest is Keystore-wrappedgrep -aoF 'getGenericPassword' B ; grep -aoF 'keyDeriveKEK' Breact-native-keychain plus a device-id KDF
Wepin mode uploads the recovery phrasegrep -aoF 'v2-api.wepin.io' B ; grep -aoF '/auth/publickey' Bto a provider-supplied public key
A cleartext RPC node, on a test networkgrep -aoF 'http://158.247.251.208:8545' B ; grep -aoF 'DSRV Testnet' Bone hit each; that chain entry carries isTestnet: true
The bridge reaches the clipboardgrep -aoF 'get_clip_board_text' Ba native command a bridged page can call
Amplitude, the third analytics kitgrep -aoF '[Amplitude] Initialized successfully' BAppsFlyer and Firebase are in the DEX class names
A developer TLS-trust path shipsunzip -p apk classes4.dex | grep -aoF 'maybeProceedDevSslTrust'called from the WebView SSL-error handler
The wallet address is a stored settinggrep -aoF 'setWalletWebUrl' Bwritten from the compiled value or by hand, never from a message
The social mode is a registered client of the vendor’s own Wepin servicegrep -aoF 'WEPIN_DOMAIN_ANDROID' B ; grep -aoF 'delivery.wepin.io' Bapp 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.