用途
本技能面向在互联网计算机(IC)上开发 Canister 的开发者,提供针对 IC 特有安全风险的正确编码模式,适用于 Motoko 与 Rust。IC 的异步消息模型会产生 TOCTOU(检查时与使用时不一致)漏洞,本技能给出访问控制、重入防护、异步安全与回调 trap 处理的复制粘贴级正确写法。
主要能力
本技能梳理了 IC Canister 开发的常见安全陷阱,并给出对应模式:
- 不要依赖 canister_inspect_message 做访问控制。该钩子只在单个副本上运行、无完整共识,若该副本作恶可跳过检查直接执行 update 调用;且它永远不会被跨 Canister 调用、query 调用或管理 Canister 调用触发。始终在每个 update 方法内部重复访问检查,inspect_message 仅作为节省 cycles 的优化,绝不能当作安全边界。
- 必须拒绝匿名 Principal。每个需要认证的端点都要检查调用者不是匿名 Principal(2vxsx-fae)。Motoko 用 Principal.isAnonymous(caller),Rust 比较 msg_caller() != Principal::anonymous()。否则未认证调用者可调用受保护方法;若 Canister 用调用者 Principal 作为身份键(如余额),匿名 Principal 将成为任何人可用的共享身份。
- 防止 TOCTOU。在跨 Canister 调用 await 期间,其他消息可交错并修改状态,这是 IC 上 DeFi 漏洞的主要来源之一。使用按调用者加锁(CallerGuard 模式)阻止并发操作;金融操作还可考虑 saga 模式(先扣减后 await,失败补偿),但正确实现复杂,因为存在回调 trap 与调用超时等结果不确定的边界情况。
- 避免在 pre_upgrade 中 trap。若 pre_upgrade trap(如序列化数据过多超指令限制),Canister 将永久无法升级。避免在堆中存放大数据结构。Rust 用 ic-stable-structures 直接访问稳定内存;Motoko 的 persistent actor 声明会自动把 let 与 var 变量存入稳定内存,无需手动序列化。
- 监控 cycles 余额。每个 Canister 默认 freezing_threshold 为 2,592,000 秒(约 30 天)。cycles 低于阈值储备时 Canister 冻结(拒绝所有 update 调用);归零时被卸载,代码与内存移除,但 Canister ID 与控制器保留。真正的陷阱是不主动监控与充值。对持有宝贵状态的生产 Canister,应提高冻结阈值并设置自动监控。
- 避免单一控制器无备份。若丢失控制器身份的私钥,Canister 将永久无法升级,没有恢复机制。始终添加备份控制器或治理 Canister。部署时询问开发者是否有备份控制器 Principal 可添加。
- 不要在本地开发中调用 fetchRootKey()。它在运行时从副本获取根公钥并信任返回值,若该代码路径在主网运行,中间人可替换密钥破坏所有验证。永远不要调用它,即使放在环境检查后——环境分支正是该调用泄漏到生产构建的方式。应改为从 ic_env cookie 读取根密钥(IC_ROOT_KEY,通过 @icp-sdk/core/agent/canister-env 的 safeGetCanisterEnv()),并作为 rootKey 选项传给 HttpAgent.create()。该 cookie 由静态站点/资产 Canister 前端和 icp-cli Vite 开发服务器自动设置,同一代码在本地与生产均可用,无需分支。
- 不要暴露无守卫的管理方法。每个 update 方法都可被互联网上任何人调用。管理方法(迁移、配置、铸造)必须显式检查调用者在允许列表中。没有内置角色系统,必须自行实现,并始终包含管理员撤销。
- 不要在 Canister 状态中存储机密。标准应用子网上的 Canister 内存可被节点操作员读取。绝不要存储私钥、API 密钥或密码。链上机密管理应使用 vetKD(阈值密钥派生)。
- 不要允许无限制的用户控制存储。若用户可无限制存储数据,攻击者可填满 4 GiB Wasm 堆或稳定内存导致 Canister 瘫痪。始终实施每用户存储配额并校验输入大小。
- 避免在状态变更后的回调中 trap。若 Canister 在跨 Canister 调用前修改了状态,而回调 trap,调用前的修改会保留,回调的修改会回滚。恶意被调用方可利用此点跳过账户扣减等安全关键操作。应让关键状态变更发生在异步边界之前,并在失败或 trap 时正确回滚。用 try/finally(Motoko)或 Drop 守卫(Rust)确保清理始终执行,清理代码保持最小。
- 避免无界等待调用阻碍升级。若 Canister 调用了一个永不响应的不可信或有 bug 的被调用方,在等待未完成响应期间 Canister 无法停止(因而无法升级)。使用有界等待调用(超时)确保调用在有界时间内完成。
使用方式
技能说明 IC 安全模型:update 调用经共识,子网上所有节点执行代码并须对结果达成一致,标准应用子网有 13 个节点,系统与信托子网更多(28+),因此防篡改但较慢(约 2 秒);query 调用在单副本运行,快(约 200 毫秒)但副本可返回错误或恶意结果,副本签名 query 提供部分缓解,完全信任应使用认证数据或 update 调用进行安全关键读取;跨 Canister 调用是异步消息,发送请求与收到响应之间 Canister 可处理其他消息,状态可能变化;消息执行 trap 时其状态变更全部回滚,跨 Canister 调用的首次执行(await 前)与回调(await 后)是独立消息,回调 trap 只回滚回调的变更,首次执行的变更保留,因此清理逻辑(如释放锁)必须放在清理上下文(finally/Drop)而非普通回调代码中。
Motoko 部分提供:用 shared(msg) 模式原子捕获部署者;requireAuthenticated、requireOwner、requireAdmin 守卫;addAdmin、removeAdmin 管理方法;公共与管理员端点示例。重入防护(CallerGuard 模式)用 Map 记录 pendingRequests,acquireGuard 与 releaseGuard,在 try/finally 中释放锁。inspect_message 示例匹配公共方法变体,对管理方法拒绝匿名以节省 Candid 解码的 cycles。
Rust 部分提供:用 CDK guard 属性在方法体前运行检查,guard 返回 Err 则拒绝调用;require_authenticated、require_owner、require_admin 守卫;init 设置 owner;用 #[update(guard = "...")] 标注端点;add_admin、remove_admin。重入防护用 CallerGuard 结构体与 Drop trait 在作用域结束时释放锁(含回调 trap 时),注意不要写 let _ = CallerGuard::new(caller)?,这会导致守卫立即丢弃使加锁失效。示例使用 Call::bounded_wait 进行有界等待调用。inspect_message 示例用于 cycles 优化。
适用场景
适合在 IC 上构建生产级 Canister、尤其是持有资金或敏感状态的开发者,作为安全编码参考。
限制
前提条件:Motoko 需 mops 包管理器,mops.toml 中 core = "2.0.0";Rust 需 ic-cdk = "0.19"、candid = "0.10"。saga 模式的正确实现复杂,涉及回调 trap 与调用超时等结果不确定的边界情况,资料提示需谨慎。其余具体限制暂未提供。