Move Over 是 OpenZeppelin 推出的 Sui Move 安全 CTF,目前共 8 個關卡,這篇是通關後整理的筆記,記漏洞成因和開發上的注意事項,不是完整解題流程
Level 1 – Artifact
暖身關,鑄造 Artifact,充能到 100 後呼叫 shatter() 拿旗標
public fun shatter(artifact: Artifact): ArtifactFlag {
let Artifact { id, power } = artifact;
assert!(power == 100, 0);
id.delete();
ArtifactFlag {}
}
這關本身沒有漏洞,主要熟悉 Move 的 Resource 生命週期——建立、修改、消耗,Move 保證 Resource 不可複製、不會意外遺失,shatter() 透過解構 Artifact 來消耗它,是 Move 中正確銷毀資源的方式
Level 2 – Coin Collector
從自動販賣機取得 Prize,問題在 buy_prize:
public fun buy_prize(payment: Token): CoinCollectorFlag {
let Token { id, value: _ } = payment; // value 直接被丟掉
id.delete();
CoinCollectorFlag {}
}
解構時 value: _ 直接忽略金額,只要有任何 Token 就能買,不管裡面是多少,faucet() 給的 Token 可以用 split(0) 拆出一個零值 Token,用那個就過了
Move 保證資產不能被偽造,但不保證商業規則正確,缺少金額驗證在 DeFi 合約裡很常見,正確的寫法:
assert!(payment.value() >= PRICE, EInsufficientPayment);
Level 3 – Sticky Treasure
從 Chest 中取出 Prize(value = 1000),Chest 建立時把 Prize 存進 Dynamic Field1,一種讓 Object 在執行時動態新增或移除鍵值對的機制,不需要在結構定義時宣告所有欄位:
public fun create(ctx: &mut TxContext): Chest {
let mut chest = Chest { id: object::new(ctx) };
df::add(&mut chest.id, b"prize", Prize { value: 1000 });
chest
}
乍看 smash() 像是正確的取出方式,但實際上是個陷阱:
public fun smash(chest: Chest): Prize {
let Chest { id } = chest;
id.delete();
Prize { value: 0 } // 回傳假的 Prize,Dynamic Field 裡的才是真的
}
smash() 刪掉了 Chest 的 UID,但回傳的是新建的 Prize { value: 0 },真正的 Prize 還留在 Dynamic Field 裡,正確做法是 extract_prize(),透過 df::remove 把 Prize 真正移出來:
public fun extract_prize(chest: &mut Chest): Prize {
df::remove(&mut chest.id, b"prize")
}
Dynamic Field 不會因為 parent 被刪就自動清空,存進去的資料要明確 remove,否則容易出現幽靈資產
Level 4 – Flash Vault
耗盡 Flash Vault 的所有資金(初始 1000),這關用了 Flash Loan 搭配 Hot Potato Pattern
Hot Potato 是一種沒有 drop ability 的 struct,建立後必須在同一筆交易內被消耗,否則交易失敗2,Flash Loan 常靠這個確保借款一定要還——borrow() 產生 Receipt(Hot Potato),必須在交易結束前呼叫 repay() 消耗它
但 cancel() 的實作有問題:
public fun cancel(vault: &mut FlashVault, receipt: Receipt, ctx: &TxContext) {
let Receipt { vault_id, borrower, nonce, amount: _ } = receipt; // amount 被忽略
assert!(vault_id == object::id(vault), EWRONG_VAULT);
assert!(borrower == ctx.sender(), EWRONG_BORROWER);
assert!(nonce + 1 == vault.next_nonce, ENONCE_MISMATCH);
// Receipt 消失了,但借出的錢完全沒有還回去
}
Receipt 被正確驗證後銷毀,vault.balance 卻沒有恢復,借出的資金就留在借款人手中,Hot Potato 的保護點不是 Receipt 存在,而是銷毀 Receipt 必須跟還款綁定,只驗證合法性、忘了驗證還款,保護就失效了
Level 5 – Pool Party
耗盡 Pool A(Treasury,餘額 100,000)的流動性,系統有三個 Pool(A/B/C),用 Receipt 追蹤借款,問題在 repay() 的參數設計:
public fun repay(
pool: &mut Pool, // 要還款的 Pool(誰接收資金)
receipt_pool_id: ID, // 呼叫者自行傳入
receipt: Receipt,
repayment: u64,
ctx: &TxContext,
) {
let Receipt { pool_id, borrower, nonce, amount } = receipt;
assert!(pool_id == receipt_pool_id, EINVALID_POOL_ID); // 只比對呼叫者傳的 ID
assert!(nonce + 1 == pool.next_nonce, ENONCE_MISMATCH); // nonce 對的是當前 pool
pool.balance = pool.balance + repayment; // 錢進了當前 pool
}
Receipt 的 pool_id 只跟呼叫者自行傳入的 receipt_pool_id 比對,完全沒有跟正在操作的 pool 比較,這讓 Pool A 的 Receipt 可以拿去還 Pool C 的款,Pool A 的資金因此流失
Sui 裡 Object ID 才是真正的身分識別3,正確做法應該驗證 Receipt 和當前 Pool 是否來自同一個物件:
assert!(receipt.pool_id == object::id(pool), EInvalidReceipt);
只比對欄位值而不驗證物件本身,就可能出現跨物件混用
Level 6 – Tick Tock
從流動性池提取大量資金(Token Balance 降至 100,000 以下),問題在 add_liquidity 的成本計算
Move 沒有浮點數,DeFi 協議通常用 Fixed Point Arithmetic4 搭配整數模擬小數——用固定的 scale factor 做縮放,需要特別注意除法截斷和溢位邊界:
const SCALE: u128 = 1 << 64;
public fun tick_to_sqrt_price(tick: u64): u128 {
let shift = (tick / 100) as u8;
1u128 << shift // tick = 0 → sqrt_price = 1
}
public fun add_liquidity(..., liquidity_amount: u128, tick: u64, ...) {
let sqrt_price = tick_to_sqrt_price(tick);
let cost = (liquidity_amount * sqrt_price) / SCALE;
assert!((deposit as u128) >= cost, 0);
}
SCALE = 2^64,當 tick = 0 時 sqrt_price = 1:
cost = (liquidity_amount * 1) / 2^64
只要 liquidity_amount < 2^64,cost 就被整數截斷為 0,等於用 0 deposit 拿到大量流動性份額,再透過 remove_liquidity 幾乎提空整個池子
常見的防法是先乘後除、設最小成本,或提高 Fixed Point 精度
Level 7 – Mailbox
取得原本寄給 ADMIN(0xAD3171)的信件,信件的取出有兩種方式,其中 claim_via 接受 RelayHandle 作為身分證明
這邊用的是 Capability Pattern5 用一個只有授權方才能持有的 struct 作為權限憑證,比直接驗證 sender address 更具擴展性:
public fun claim_via(office: &mut PostOffice, letter_id: ID, handle: &RelayHandle): Letter {
// ...(省略:從 office 取出 letter_id 對應的信件)
assert!(letter.addressee == mailbox_relay::target(handle), EWRONG_RECIPIENT);
letter
}
設計方向沒問題,但 RelayHandle 的發放毫無限制:
public fun handle_for(target: address, ctx: &mut TxContext): RelayHandle {
RelayHandle { id: object::new(ctx), target }
// 完全沒有驗證 ctx.sender() == target
}
任何人都可以替任意地址建立 Handle,Capability Pattern 的前提是只有授權方才能持有,這裡的持有門檻是零,形同虛設,正確做法:
assert!(ctx.sender() == target, EUnauthorized);
Level 8 – Night Ledger
用 1 單位的 Margin 取得 value ≥ 10,000,000 的 MarginNote,開倉時用 checked_shlw 計算所需保證金,這個函式的 Overflow 檢查有 bug
看 codebase 裡其實同時存在兩個版本:
// 實際使用的(有 bug)
public fun checked_shlw(n: u128): (u128, bool) {
let mask = 0xffffffffu128 << HIGH_BITS_OFFSET; // HIGH_BITS_OFFSET = 96
if (n > mask) { (0, true) } // mask = 2^128 - 2^96,幾乎不可能觸發
else { (n << SHIFT_BITS, false) } // SHIFT_BITS = 32
}
// 正確版本(在 codebase 裡也有,但沒被使用)
public fun checked_shlw_strict(n: u128): (u128, bool) {
let limit = 1u128 << HIGH_BITS_OFFSET; // 2^96
if (n >= limit) { (0, true) } // 正確邊界
else { (n << SHIFT_BITS, false) }
}
mask = 0xffffffff << 96 = 2^128 - 2^96,真正應該攔截的是 n >= 2^96(超過就左移 32 位元會溢位),但 buggy 版本只攔截 n > 2^128 - 2^96,中間 [2^96, 2^128 - 2^96] 這一大段全漏掉了
特定的 liquidity 值繞過 Overflow 檢查後,計算出的保證金因為整數溢位縮至極小,關倉時透過 payout_from_liquidity 換出遠超預期的金額:
public fun payout_from_liquidity(liquidity: u128, shift: u8): u64 {
let scaled = (liquidity >> shift) as u64; // shift = 28
if (scaled == 0) { 1 } else { scaled }
}
自行實作數學函式比想像中危險,尤其是位元運算和 Fixed Point 計算,能用經過審計的函式庫就優先選用,必須自己寫的話 Fuzz Testing 和完整邊界測試不可少
Completion

