Lustrous
Description¶
"In a world inhabited by crystalline lifeforms called The Lustrous, every unique gem must fight for their way of life against the threat of lunarians who would turn them into decorations." – Land of the Lustrous
nc lustrous.chal.hitconctf.com 31337
land_of_the_lustrous.vy
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 |
|
Solution¶
- Initially, there are 1,000,000 ether deposited into the contract, which we have to drain and solve the challenge. Only the
battle()
function in the contract can obtain ether -
The battle has three stages, each corresponding to a lunarian with different health and attack power. At each stage, if gem is still active and has more health than the lunarian after all rounds are over, the corresponding stage funds will go to us. Getting 1 or 2 ether each time has little effect, while the instance will automatically terminate in 10 minutes. Obviously, we have to win stage 2
-
The winner of each round in a stage is determined by
lunarian_actions
array and an array returned bymaster.get_actions()
. We don't have permission to call thebattle()
function, but we can obtainlunarian_actions
from the pending transaction. Then, front run the transaction ofbattle()
to setgem_actions
and win each round as desired :D -
The lunarian in stage 2 has a huge amount of health but the initial attack of the gem is only 255 at most. It costs 1 ether to create a gem, and we only start with 1.5 ether. Even if we can merge gems to increase the attack power of a gem, it is still difficult to win in 300 rounds
-
A master can have multiple gems. The data of each gem is stored in the contract via
gem_id
. Thegem_id
is the hash ofmaster_addr
and a sequence number. Theget_gem_id()
internal function uses theconcat
built-in function, which is related to a memory buffer overflow vulnerability1. That is, if a function calls an internal function that usesconcat
, the leading bytes of its first declared variable may be overwritten with zeros. Inget_gem_id()
,sequence_bytes
will be mloaded and mstored right after previous copiedmaster_addr_bytes
, causing a 20-byte memory buffer overflow -
During the battle, if the gem is not in an active health,
master.decide_continue_battle
will be called, giving us the opportunity to merge gems and get a gem with negative health. By combining the vulnerability ofconcat
, we can obtain a gem with high health to win the battle ;)
Exploitation¶
The exploitation steps are as follows:
- Create
gem0
with a favorable attack value and win stage 0 with it - Create
gem1
with a favorable health and draw stage 1 to reset stage to 0 without losing any health - Intentionally lose stage 0 with
gem1
to make its status inactive - Win stage 0 with
gem0
- In stage 1,
gem0
's health can go negative with only one attack. During themaster.decide_continue_battle()
,gem0
is still active and can pass the status check inmerge_gems()
along withgem1
. After merging,gem0
has enough health to win stage 1 and 2
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 |
|
Flag¶
hitcon{f1y_m3_t0_th3_m00n_3a080ea144010d74}
Appendix¶
- By checking the author's writeup, there is another vulnerability2 that is also expected to be utilized
- Argument(s) is (are) encoded as a tuple. The ABI encoding of a tuple consists of two areas: the statically encoded
head
and the dynamically encodedtail
. For dynamic types, the head contains the offset of the location within the tail where the data is stored -
Prior to Vyper 0.4.0, the ABI decoder does not have a buffer overflow check for dynamic offsets. To decode data containing dynamic types, the decoder will retrieve the actual data with the offset information and the raw data location in the memory
Vyper stores all variables in memory, including primitives
-
Take
_abi_decode(x, Bytes[32])
as an example. Assume that the variablex
is stored in memory starting from 0x140. According to the specified type, the decoder will get the actual data position by calculatingoffset+0x160
. In this example, the bytes string data is stored in memory starting from 0x180 (0x20+0x160) -
By setting the offset to a value that causes the position calculation to overflow, it is possible to decode arbitrary data in the memory. In the above example, we can set the offset to
0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0
to decode data located at0x100
- In this challenge, we can let
master.get_actions()
return a byte string containing a specific offset to achieve the purpose of copying the lunarian's actions without front-running