Featured image of post R3coin - R3CTF 2025

R3coin - R3CTF 2025

This challenge involves a blockchain-like system that uses a chameleon hash function for transaction integrity. The goal is to exploit the chameleon hash properties to forge a transaction “Selling|flag|x” that allows us to buy the flag.


Mechanics

The app lets up perform a set of actions to interact with the chain:

  1. Work: Gives us some money
  2. Buy Gift: Appends a “Buy” entry followed by a “Selling|gift|100” onto the chain
  3. Refund oldest: Turns the oldest “Buy” transaction into a “Work|x” entry by internally forging a satisfying hash. (This is our entry point)
  4. Show: pretty obvious xd
  5. Rewrite: Gives us an arbitrary write to change any transaction in the chain.

Vulnerability Analysis

Since the concept of Chameleon hashes is pretty new to me, I wanted to look it up in some papers to better understand it. While there were some pretty interesting ones, especially this one, there wasn’t a clear implementation to compare to the challenge.

What also threw me off is that the challenge linked the library’s github repo as a hint, leading me to think that there was an unpatched issue with the library of some kind, but that led nowhere either.

I decided to play the cards I’ve been dealt, and read the code to know what exactly I’m poking into.

Chameleon Hashes

This hashing primitive is structured in a way similar to public key cryptosystems, where the hashing operation is done with a public and a private key. The hashing operation itself is done using the public key, and whatever party holds the corresponding private key can forge a collision for any message to any corresponding hash.

In this implementation, the Public Key is self.pp, with the Private Key being self.td_ID, so it would be pretty obvious that our goal is to recover td_ID, but that didn’t occur to me straight away xd

The Rabbit Hole

Before looking for a way to recover the private key, I wanted to inspect the hashing function itself:

1
2
def _Hash(self, L, m, r):
    return self.pp[6] ** m * self.pp[5] ** r[0] * pair(r[1], self.pp[1] / (self.pp[0] ** self.ID)) * pair((self.pp[4] / (self.pp[3] ** self.ID)) ** L, r[2])

Since all the variable that come into play here are pretty known, I thought that it might be a good idea to find a way to substitute m while tweaking the randomness to keep the same hash value.

But if you look at this for more that 5 seconds, it’ll be clear why this is pretty much impossible: m is introduced as an exponent, paired with the fact that pairing group operations are done in a finite field, makes it Discrete Log Problem. So unless I have access to some advanced quantum computer, this path is pretty much dead.

The LEAK

Going back to the private key, we must find a way to somehow leak it. And surprise surprise, the server-side collision function does just that!

To recap, Col is the function that attempts to find a new randomness that would let us forge a satisfying hash for some new message. It derives td_b_ID from the private key to prevent reuse attacks, but it’s not prevented here.

If you look closely at the new randomess:

1
return (r[0] + (m - m_p) * td_b_ID[0], r[1] * (td_b_ID[1] * (self.pp[4] / (self.pp[3] ** self.ID)) ** (s * L)) ** (m - m_p), r[2] * (td_b_ID[2] * (self.pp[1] / (self.pp[0] ** self.ID)) ** s) ** (m_p - m))

the derived secret key is linear to the randomness value (and some other value which doesn’t really matter).

The solution

Since each component gof the derived secret is linear to Δ_ri (the difference between new_randomness_i and old_randomness_i) aswell as the difference between the messages, it essentially boils down to solving a linear equation to recover tdb_ID. The solution also eliminated the new-added randomness (s,tp) so we pretty much end up with The private key td_ID.

With the private key recovered, we can forge collisions just like the server, and the rest is history

Script

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
from charm.toolbox.pairinggroup import PairingGroup, ZR
from pwn import *
from tqdm import trange

Local = False
if Local:
    HOST = "192.168.1.11"
    PORT = 1445
else:
    HOST = "s1.r3.ret.sh.cn"
    PORT = 32316

io = remote(HOST, PORT)
P = PairingGroup("SS512")
io.recvuntil(b"number! ")
ll = io.recvline().decode().strip()[1:-1].split(", ")
pp = [P.deserialize(x[1:-1].encode()) for x in ll]


def send_cmd(i: int):
    """
    1. Work, work
    2. Buy gift
    3. Refund oldest gift
    4. Show my bill
    5. Write my bill
    6. Run payment
    """
    io.recvuntil(b"Run payment\n> ")
    io.sendline(str(i).encode())

def get_bill():
    send_cmd(4)
    lines = io.recvuntil(b"END", drop=True).decode().strip().split("\n")
    bill = []
    counter = 0
    entry = {}
    for l in lines:
        match (counter %4):
            case 0:
                hp = l.split(" ")[1]
                entry["HP"] = P.deserialize(hp.encode())
            case 1:
                r = l[5:-2].split("', '")
                R = tuple(P.deserialize(x.encode()) for x in r)
                entry["R"] = R
            case 2:
                L = l[3:]
                entry["L"] = L
            case 3:
                M = l[3:]
                entry["M"] = M
        if counter %4 == 3:
            bill.append(entry)
            entry = {}
        counter +=1
    return bill

def hashy(e):
    return P.hash(e, ZR)
send_cmd(2)

bill_old = get_bill()
c = bill_old[0]["HP"]
target_block_old = bill_old[1]
R_old = target_block_old["R"]
HP_prev = target_block_old["HP"]
M_old = target_block_old["M"]
L_1 = target_block_old["L"]

full_M_old = P.serialize(HP_prev).decode() + M_old

send_cmd(3)
hint_hash_m = io.recvline().decode().strip()
hint_hash_mp = io.recvline().decode().strip()
bill_new = get_bill()
target_block_new = bill_new[1]
R_new = target_block_new["R"]
M_new = target_block_new["M"]

full_M_new = P.serialize(HP_prev).decode() + M_new


m_hash_old = hashy(full_M_old)
m_hash_new = hashy(full_M_new)
delta_hash_observed = m_hash_old - m_hash_new
delta_m_inv = delta_hash_observed ** -1
rec_t = (R_new[0] - R_old[0]) * delta_m_inv

L_hash = hashy(L_1)
t1 = (R_new[1] / R_old[1]) ** delta_m_inv
t2 = (R_new[2] / R_old[2]) ** (c * L_hash * delta_m_inv)
rec_td_ID_1 = t1 * t2

ID = hashy("ADMIN")
td_ID = (rec_t, rec_td_ID_1)
def Col(L, m, r, m_p):
        L = P.hash(L, ZR)
        m = P.hash(m, ZR)
        print(f"{P.serialize(m).decode()}")
        m_p = P.hash(m_p, ZR)
        print(f"{P.serialize(m_p).decode()}")
        s, t_p = P.random(ZR), P.random(ZR)
        td_b_ID = (td_ID[0], td_ID[1] * (pp[4] / (pp[3] ** ID)) ** (L * t_p), (pp[1] / (pp[0] ** ID)) ** t_p)
        return (r[0] + (m - m_p) * td_b_ID[0], r[1] * (td_b_ID[1] * (pp[4] / (pp[3] ** ID)) ** (s * L)) ** (m - m_p), r[2] * (td_b_ID[2] * (pp[1] / (pp[0] ** ID)) ** s) ** (m_p - m))

target = "Selling|flag|0"
l = target_block_new["L"]
m = full_M_new
r = R_new
m_p = P.serialize(HP_prev).decode() + target

forged_r = Col(l,m,r,m_p)
forged_r_repr = tuple(P.serialize(x).decode() for x in forged_r)
send_cmd(5)
io.sendline(b"1")
io.sendline(target.encode())
io.sendline(str(forged_r_repr).encode())

#----WOOOOOOORK
for _ in trange(30):
    send_cmd(1)

send_cmd(2)
send_cmd(2)
send_cmd(2)
send_cmd(6)
io.interactive()

R3CTF{N0w-Y0U_@2E-7H3-GoD-1n-THe_D0M4IN-0f_id3NTl7y-b@5ED_ChaMeleon_H@sh_YOu_c4n_d0_any_7r@d30}