(flashback)
Me: Hey, I think this challenge uses a faulty version of cosmos/iavl. Cuz you know, like, according to the go.mod file, it uses v0.19.2, which is not the latest version. And then there is this change log of version v0.19.3 saying it prevent sth sth and then there is this blog page posted around that release saying iavl is not good etc etc. I think this is the vulnerability.
L.T.: I think ndh doesn't want to tease our brain too hard, so he wouldn't come up with sth this complex. Let's crack the random function.
Me: I agree. There are so many words and crazy stuffs in this blog so it's probably isn't true.
L.T.: Yeah...
Me: Yeah...
Well, that turns out to be the intended vulnerability of the challenge. But we're not here to discuss that (that blog still hurts my brain btw :'() because we actually figured out a way to crack the random function 😄 (yey)
I've seen the discussion around the challenge and I saw players talking about brute-forcing with multiple cores to get the seed of Go's PRNG. Well, we actually found a way to predict the result without having to find the seed 😗
Although this approach only gives us the correct output
⚠️ (Because I want to write a blog about the details of my discovery, including methods to find it, other methods you can go, what's the challenge is all about, etc, ..., this blog will be a very, probably unnecessarily, lengthy one, and if you're not ready to read it, here is a small recap on what's about to come 🙂)⚠️
(now come to think of it I might have put the whole stream of consciousness in here...)
Consider a list of outputs from rand.Intn(n)
named n
is a relatively small number
and we use one of these 4 equations as our strategy to guess the next output. Each round we bet balance
/2 (or some number that is linear to balance
, like balance
/3 or sth, your pick 😙). This strategy allows our money not to run out too quickly when we guess the wrong number while still guarantees our money growing exponentially if we win. Since win amount is balance
.
Casino 2 - Attack on gorand - TETCTF-2023TL;DRTable of contentsChallenge descriptionAnalysisThe IAVL bugThe Go math/rand
stuffFinding pattern in rand.Intn(n)
Graphing the CallsFormualize itMore PatternThe tale of adding two numbersPiecing the puzzle togetherTake your balance
to the infinity and beyond 🚀AppendicesSolve scriptModifying dockerfileDockerfile (modified)compose.yml
(if you know all the functionalities of the code files, you may skip this section anyways 🙂 )
This challenge is about a Go Application that emulates some sort of online casino stuffs. You interact with the server by sending some JSON data as input, and expect some text data as output.
You can:
register your username
and get balance
. The server also restricts you from registering more than 100 accounts or creates another account with an existing username
, but it doesn't matter in the context of solving this challenge anyways.
example request:
{ "recipient": "Casino", "command": "Register", "username": "Orrrrrr" }
code:
x// casino.go
// ...
const MaxPlayers = 100
const InitialBalance = 2023
func (c *Casino) Register(username string) error {
exist, err := c.tree.Has([]byte(username))
if err != nil {
log.Fatal(err)
}
if exist {
return errors.New("player-exists")
}
if c.numAccounts >= MaxPlayers {
return errors.New("max-players")
}
c.numAccounts += 1
c.setBalance(username, big.NewInt(InitialBalance))
fmt.Printf("Added user: %s.\n", username)
return nil
}
// ...
bet a number n
between amount
(if you put in a negative number, the server will correct it to become positive anyways 🤧 -- this became the exploit point of the first challenge, Casino 1, because it didn't check whether the amount is negative or not.)
The server also generates a number n
between n
n
, you will lose :<, else you will win :>
amount
and you will have balance
- amount
coin units left.amount
.Also, the server will tell you what n
they generated. ( <- This will become important later on )
example request:
xxxxxxxxxx
{ "recipient": "Casino", "command": "Bet", "username": "Orrrrrr", "amount": 123, "n": 456 }
code:
xxxxxxxxxx
// casino.go
// ...
func (c *Casino) Bet(username string, amount *big.Int, n int) error {
currentBalance, err := c.getBalance(username)
if err != nil {
return err
}
amount.Abs(amount)
if currentBalance.Cmp(amount) < 0 {
return errors.New("insufficient-balance")
}
r := rand.Intn(2023)
if r == n { // correct guess
reward := new(big.Int).Mul(amount, big.NewInt(2022))
currentBalance.Add(currentBalance, reward)
c.setBalance(username, currentBalance)
fmt.Printf("YOU WIN! Current balance: %d (+%d).\n", currentBalance, reward)
} else {
currentBalance.Sub(currentBalance, amount)
c.setBalance(username, currentBalance)
fmt.Printf("YOU LOSE (%d != %d)! Current balance: %d (-%d).\n", r, n, currentBalance, amount)
}
return nil
}
// ...
show balance with proof: You give your username
to the server and it gives you the balance
along with a proofData
to prove that you actually own that amount of money.
example request:
xxxxxxxxxx
{"Recipient": "Casino", "Command": "ShowBalanceWithProof", "Username": "Orrrrrr"}
code:
xxxxxxxxxx
// casino.go
// ...
func (c *Casino) ShowBalanceWithProof(username string) error {
value, proof, err := c.tree.GetWithProof([]byte(username))
if err != nil {
log.Fatal(err)
}
if value == nil {
return errors.New("player-not-exist")
}
proofData, err := proof.ToProto().Marshal()
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d, %s\n", new(big.Int).SetBytes(value), base64.StdEncoding.EncodeToString(proofData))
return nil
}
// ...
get the flag. In order to retrieve the flag, we need to provide our balance
. We also need to provide a proofData
, which is generated from the previous section and it's there so that we cannot lie about our balance
. The more balance
we have, the more bytes of the flag is revealed to us.
example request:
xxxxxxxxxx
{"Recipient": "FlagSeller", "Command": "PrintFlag", "Username": "Orrrrrr", "Balance": 674575536530165491749660868130299036066707815144061494924879197906525335081820072153268675251126472982500904339158951753600, "proof_data": [26, 46, 10, 7, 79, 114, 114, 114, 114, 114, 114, 18, 32, 109, 131, 92, 77, 137, 187, 133, 233, 12, 114, 115, 54, 175, 113, 149, 190, 174, 14, 165, 57, 118, 134, 67, 152, 122, 170, 139, 119, 113, 127, 202, 200, 24, 186, 6]}
code:
xxxxxxxxxx
// flag_seller.go
// ...
func (fs *FlagSeller) PrintFlag(usename string, balance *big.Int, proofData []byte) error {
var pbProof iavlproto.RangeProof
if err := proto.Unmarshal(proofData, &pbProof); err != nil {
return fmt.Errorf("bad proof format: %w", err)
}
proof, err := iavl.RangeProofFromProto(&pbProof)
if err != nil {
return fmt.Errorf("bad proof format: %w", err)
}
if err := proof.Verify(fs.dbRootRetriever()); err != nil {
return fmt.Errorf("proof verification failed: %w", err)
}
if err := proof.VerifyItem([]byte(usename), balance.Bytes()); err != nil {
return fmt.Errorf("proof verification failed: %w", err)
}
l := balance.BitLen() / 8
dot3 := "..."
if l >= len(fs.flag) {
l = len(fs.flag)
dot3 = ""
}
fmt.Printf("Your flag is: %s%s\n", fs.flag[:l], dot3)
return nil
}
// ...
The number of flag bytes revealed to us scales linearly with the number of bytes there are in our balance
, so we probably need to have a lot of money to get the full flag. For example, if the flag is
There are two ways we may want to approach this problem.
First one is the IAVL bug. You can see that users' data is stored in the Casino
structure, which contains a tree
object. This tree
data type comes from github.com/cosmos/iavl
library.
xxxxxxxxxx
// casino.go
import (
// ...
"github.com/cosmos/iavl"
// ...
type Casino struct {
tree *iavl.MutableTree
numAccounts int
}
func NewCasino() *Casino {
tree, err := iavl.NewMutableTree(db.NewMemDB(), 128, true)
if err != nil {
log.Fatal(err)
}
// ...
Upon inspecting the go.mod
file, you can see that it uses version v0.19.2
, which is nothing surprising until you find out that the real-world IAVL
is version v0.19.4
by the time the challenge was released.
xxxxxxxxxx
// go.mod
module casino
go 1.19
require (
github.com/cosmos/iavl v0.19.2
github.com/golang/protobuf v1.5.2
github.com/tendermint/tm-db v0.6.6
)
So, I began searching for clues, like "iavl v0.19.2 attack", "cosmos iavl v0.19.2 tree vulnerability attack", then I came upon this blog:
which was suspicious, because it released around the time of v0.19.3
according to this change log. " Pass IAVL Proof Verification?" Maybe there's some way we can craft a fake proofData
to send to the PrintFlag
function for any balance
we wish to? Anyways, reading through the blog (not the one I screenshot-ed above, this one, dunno why it's not in Google search anymore 😕 that one's just for showcase that we tried to find stuffs), it seems like whatever bug they found, it was really bad. So I thought to myself: This must be it.
Then I noticed the change in v0.19.3
, it seems like the change in the newer version suggests something about the attack we should come up with :)
However, our investigation about the tree stuff ends here, because this is where me and my teammate stopped (you know why 🙂); and also this blog's about attacking the Go's rand
stuff 😗 If you want to read more about the IAVL bug, you should visit these two links, provided by a fellow Discord member:
math/rand
stuffThe second way you could dig at this challenge is by exploiting the rand.Intn(n)
function from math/rand
library. Remember, the server creates random numbers in
We can try to go all random; however, that only guarantees us to get the correct answer in a probability of balance
for winning and -balance
for losing, it seems like we will just be stuck in the same loop forever if we do that.
If only somehow you can find a hidden pattern in the way the server generates the numbers, which is just this line of code btw :333
xxxxxxxxxx
r := rand.Intn(2023)
then you can use it to predict future values, keep winning and rocketing yourself to your target balance
. And from the title, you already know that it is possible.
The reason this is feasible because the math/rand
library "implements pseudo-random number generators" which is "unsuitable for security-sensitive work" according to this link. It also states that "This package's outputs might be easily predictable regardless of how it's seeded." Great! But how predictable is it?
A pseudo-random number generator, or PRNG, like this would generate all of their supposed random numbers based on a seed
value. If you know the seed
, you basically become god and predict all n
values in the bet game.
As some players have pointed out, it is possible to recover the seed
of Go's PRNG, because:
This means we just need to brute-force every value from seed
, we set seed by rand.Seed(seed)
then call rand.Intn(2023)
a few times and compare the outputs with the server's n
(s), which can be obtained for free by keep betting seed
, we just need to keep betting with our balance
with n = rand.Intn(2023)
to get the flag.
While it is very simple to setup the code to do this, an hour, to me, still feels like it's too much. If you code wrong (probably you won't but who knows?), you would've to spend another hour to debug 🙂 Luckily tho, we can find the pattern of rand.Intn(2023)
without the seed 😉, thus solving this challenge in a matter of seconds.
rand.Intn(n)
xxxxxxxxxx
r := rand.Intn(2023)
Staring at a single line of code won't help a thing. So I decided to use the power of ctrl-click-into-a-function-because-it-helps-us-going-back-to-its-definition-and-i-hope-i-could-have-a-better-name-for-this in VSCode and dive into the code hierarchy and here is the progress:
As you can see, we've got pretty much everything up until the Int63()
function. To look at the code for that, we need to go to rng.go
, which is at the same directory as rand.go
(the reason I got this is because my friend found the code for Int63()
at https://go.dev/src/math/rand/rng.go, and I was like wait aren't we also have this file in the system too?), and we've got the complete picture:
While the above graph looks kinda crazy (or not, idk...), when we create an abstract map for them, it is actually surprisingly simple if we just focus on the case where n = 2023
or n
is a relatively small number to
The graph above can be simplified as a formula in Python:
xxxxxxxxxx
rand.Intn(n) == ((rand.Uint64() & ((1<<63) - 1)) >> 32) % n # n is small
Because taking the last
xxxxxxxxxx
rand.Intn(n) == ((rand.Uint64() >> 32) & ((1<<31) - 1)) % n # n is small
we can see that & ((1<<31) - 1))
is just % (2**31)
, so:
xxxxxxxxxx
rand.Intn(n) == (rand.Uint64() >> 32) % (2**31) % n # n is small
This implies if we have two Go programs set to the same seed
, one outputting 1 number from rand.Intn(n)
and the other outputting 1 number from rand.Uint64()
, then the above equation holds.
... You're probably wondering why I keep mentioning n
is small in the code comment. Well, the above statement is not entirely true. It only happens in high probability for small n
. For big n
rand.Intn(n)
may call rand.Uint64()
multiple times before outputting a number. If we investigate into the rand.Int31n(n)
function,
xxxxxxxxxx
func (r *Rand) Int31n(n int32) int32 {
// ...
max := int32((1 << 31) - 1 - (1<<31)%uint32(n))
v := r.Int31()
for v > max {
v = r.Int31()
}
return v % n
}
you can see that there also exists a max
variable, and we have to call rand.Int31()
repeatedly until we hit a value <= max
. If n
is small, max
would be very close to rand.Int31()
only once for each rand.Intn(n)
.
Oh, but there is an exception if n
is a power of n
is rand.Intn(n)
will definitely call rand.Int31()
once.
xxxxxxxxxx
// Int31n returns, as an int32, a non-negative pseudo-random number in the half-open interval [0,n).
// It panics if n <= 0.
func (r *Rand) Int31n(n int32) int32 {
// ...
if n&(n-1) == 0 { // n is power of two, can mask
return r.Int31() & (n - 1)
}
// ...
Consider rand.Intn(n)
, rand.Uint64()
setting to the same seed
starting at the same time, when n
is a small number
where rand.Uint64()
and rand.Intn(n)
.
What's great about this formula is that we can now derive a pattern for rand.Intn(n)
through the pattern of rand.Uint64()
. And it turns out the pattern of rand.Uint64()
is very simple. When you look at the code for rand.Uint64()
, you can see it get output from rng.vec
array while updating it.
xxxxxxxxxx
// /usr/lib/go-1.18/src/math/rand/rng.go
const (
rngLen = 607
rngTap = 273
rngMax = 1 << 63
rngMask = rngMax - 1
int32max = (1 << 31) - 1
)
// ...
type rngSource struct {
tap int // index into vec
feed int // index into vec
vec [rngLen]int64 // current feedback register
}
// ...
// Seed uses the provided seed value to initialize the generator to a deterministic state.
func (rng *rngSource) Seed(seed int64) {
rng.tap = 0
rng.feed = rngLen - rngTap
// ...
// Uint64 returns a non-negative pseudo-random 64-bit integer as an uint64.
func (rng *rngSource) Uint64() uint64 {
rng.tap--
if rng.tap < 0 {
rng.tap += rngLen
}
rng.feed--
if rng.feed < 0 {
rng.feed += rngLen
}
x := rng.vec[rng.feed] + rng.vec[rng.tap]
rng.vec[rng.feed] = x
return uint64(x)
}
You don't need to know how the rng.vec
is created to figure out a pattern for rand.Uint64()
. Here, you just need to consider it to be a bunch of random numbers. The important thing you'll need to care is that rng.vec
:
only uses the numbers in it to update itself.
the way it creates new number from older ones.
If you're familiar with the structure of LFSR, or Linear Feedback Shift Register, you would probably recognize this is kinda like that. The terms like tap and feed make more sense, and you can deduce the following relationship between some of the
(while it is seemingly enough to conclude x := rng.vec[rng.feed] + rng.vec[rng.tap]
line, however since rng.vec
is stored in an int64
array, we have to include the
(i think this section is kinda overkill, but whatever -_-)
What happens to the result when we add two
The other times, the result overflows, at most 1 bit into the left. In this case,
What is also interesting when we add two numbers, is that if you ignore some of
However, sometimes, since the addition in the lower
(i'm not sure if this reasoning is correct... but I'll try my best)
(at first I was going to use the figures similar to the previous section but I don't know how, so I just stick with the formula, hope this make senses to all of you 🙂)
You're probably know where this is going. Because:
... we have:
(i dunno if the notation
actually represents add or or not... But that's what I meant :))
... shift right both sides by
... and we reduce both sides
(
vanishes cuz or equals mod )
But this also means:
Now the final ingredient, taking
balance
to the infinity and beyond 🚀Now we've known how the outputs of rand.Intn(n)
are related. We can collect numbers generated by the server into a list
After
and bet
Because win amount is amount
that is proportional to balance
like balance
/2, this allows our money to grow exponentially, makes it easy to hit some crazy big number like
(i'm not sure that's optimal or not... but
balance
/2 works so I don't think too much about that 🙂)
When you get showBalanceWithProof
to get proofData
then submit it along with your balance
to PrintFlag
to grab some flag! (yey)
xxxxxxxxxx
from tqdm import trange
from pwn import remote
from sage.all import *
import base64
import json
host = '192.53.115.129'
port = int(31339)
########## Connect ###########
io = remote(host, port)
# io = process(['./src/casino'])
def request(data):
if isinstance(data, str):
data = data.encode()
elif not isinstance(data, bytes):
data = str(data).encode()
io.send_raw(data + b'\n')
def recvline():
return io.recvline().strip().decode()
def get(data):
request(json.dumps(data))
return recvline()
########### Requests #############
def register(username):
return get({
"Recipient": "Casino",
"Command": "Register",
"Username": username
})
def bet(username, amount, number):
answer = get({
"Recipient": "Casino",
"Command": "Bet",
"Username": username,
"Amount": amount,
"N": number
})
serverGuess = int(answer.split(' ')[2][1:]) if 'YOU WIN' not in answer else number
ourBalance = int(answer.split(' ')[-2])
return serverGuess, ourBalance
def showBalance(username):
answer = get({
"Recipient": "Casino",
"Command": "ShowBalanceWithProof",
"Username": username
})
ourBalance = int(answer.split(' ')[0][:-1])
proofBytes = base64.b64decode(answer.split(' ')[1])
return ourBalance, proofBytes
def printFlag(username, balance, proof):
return get({
"Recipient": "FlagSeller",
"Command": "PrintFlag",
"Username": username,
"Balance": balance,
"proof_data": list(proof)
})
########### Main #############
USER = 'mistsu'
register(USER)
print(f'[i] Collecting server outputs...')
serverOutputs = []
for i in trange(607):
serverOutput, balance = bet(USER, 0, 0)
serverOutputs.append(serverOutput)
print(f'[i] Guessing server\'s outputs with probability of 1/4...')
i = 607
while balance < 2**(50*8):
ourGuess = (serverOutputs[i - 607] + serverOutputs[i - 273]) % 2023
serverOutput, balance = bet(USER, balance//2, ourGuess)
serverOutputs.append(serverOutput)
print(f'[i] Server actual output: {serverOutput}')
print(f'[i] Our output: {ourGuess}')
print(f'[i] Balance: {balance}')
print(f'---------------------------------------------------')
i += 1
ourBalance, proofBytes = showBalance(USER)
print(printFlag(USER, ourBalance, proofBytes))
Yeah, building a docker for this challenge sucks, because it keeps yelling at us that there's no go.sum
file :< So we decided to fix the Dockerfile
, add a compose.yml
file so that we can just docker compose
it, making our lives easier 🙂 . Here are them 2 files:
xxxxxxxxxx
FROM golang:1.19-alpine
RUN apk add socat
RUN addgroup -S casino && adduser -S casino -G casino
USER casino:casino
WORKDIR /home/casino
# Create go.mod file
COPY ./go.mod .
RUN go mod download
# This is just for a reminder to put a flag file in here :)
COPY ./flag .
# Copy everything else and build binary.
COPY . .
RUN go get casino
RUN mkdir ./build
RUN go build -o ./build/casino
EXPOSE 31339
CMD socat -T 30 -d -d TCP-LISTEN:31339,reuseaddr,fork EXEC:"./build/casino"
xxxxxxxxxx
services:
casino2:
build: .
ports:
- "31339:31339"
After starting the container, you can connect to localhost:31339
to play the challenge 😗