Replay-атаки и защита через seqno/nonce

Защитишь контракт от повторного принятия того же подписанного payload через seqno или собственный nonce.

Replay и seqno

Replay — повторная подача уже валидного сообщения (или его копии), чтобы эффект сработал снова. У стандартных кошельков TON защита — поле seqno: контракт принимает только текущий номер и увеличивает его после успеха. Свой контракт с external без аналога nonce принимает один и тот же body многократно.


Уязвимый external без nonce

tact (fallback highlight)
// @tact-lang/compiler 1.6.13
// УЯЗВИМО: подпись проверяется, seqno — нет

message ExtOrder {
    amount: Int as coins;
    dest: Address;
}

contract RelayBad(
    publicKey: Int as uint256,
) {
    external(msg: ExtOrder) {
        let hash: Int = beginCell()
            .storeAddress(msg.dest)
            .storeCoins(msg.amount)
            .endCell()
            .hash();
        // Предположим, подпись уже проверена вне сниппета —
        // без seqno тот же signed payload можно слать снова.
        acceptMessage();
        send(SendParameters{
            to: msg.dest,
            value: msg.amount,
            mode: SendPayGasSeparately,
            bounce: false,
        });
    }
}

Исправленный контракт с seqno

tact (fallback highlight)
// @tact-lang/compiler 1.6.13
// ИСПРАВЛЕНО: seqno в payload + инкремент до побочных эффектов

message ExtOrder {
    seqno: Int as uint32;
    amount: Int as coins;
    dest: Address;
    signature: Slice; // ed25519 signature over the rest of the cell
}

contract RelayOk(
    publicKey: Int as uint256,
    seqno: Int as uint32,
) {
    external(msg: ExtOrder) {
        require(msg.seqno == self.seqno, "bad seqno");

        let data: Cell = beginCell()
            .storeUint(msg.seqno, 32)
            .storeCoins(msg.amount)
            .storeAddress(msg.dest)
            .endCell();
        require(
            checkSignature(data.hash(), msg.signature, self.publicKey),
            "bad sig",
        );

        acceptMessage();
        self.seqno = self.seqno + 1;

        send(SendParameters{
            to: msg.dest,
            value: msg.amount,
            mode: SendPayGasSeparately,
            bounce: true,
        });
    }

    get fun currentSeqno(): Int {
        return self.seqno;
    }
}

Как это работает

  • Клиент читает currentSeqno(), подписывает payload с этим номером, шлёт external.
  • Контракт сверяет msg.seqno == self.seqno, проверяет подпись по тому же хешу полей, затем acceptMessage() и инкремент.
  • Инкремент до send обязателен: иначе при частичном успехе возможен повтор.
  • Wallet V4/V5 уже несут свой seqno; это не заменяет nonce твоего контракта, если он сам принимает signed external.

Частые ошибки

Проверяешь подпись, но не seqno → валидная подпись ≠ одноразовость.

Инкрементируешь seqno после send → при ошибке после accept возможен повторный приём того же номера.

Кладешь в подпись не все поля → атакующий меняет dest/amount при той же подписи.


Что дальше

Материалы gramdocs.tech носят образовательный характер и не являются финансовой, юридической или инвестиционной рекомендацией. Работа с блокчейном TON и токеном Gram связана с рисками потери средств. Правовая информация