Аудит безопасности NFT-коллекции
NFT-контракты выглядят проще DeFi протоколов — нет сложной математики, нет liquidity pools. На практике это ложное ощущение безопасности: NFT коллекции регулярно теряют средства из-за уязвимостей в mint логике, reentrancy в ERC-721 transfer callback, некорректного randomness и проблем с royalty. Аудит NFT-контракта — это не формальность, это требование рынка: большинство маркетплейсов и инвесторов смотрят на наличие аудита как на baseline.
Что проверяется в NFT-аудите
ERC-721 стандарт и реализация
Первый блок: соответствие ERC-721 стандарту и корректность базовой реализации. Используется OpenZeppelin или собственный контракт?
Собственная реализация ERC-721 — немедленный red flag. Если команда не использует OZ ERC721, нужно тщательно проверить:
- Корректность
safeTransferFrom— вызовonERC721Receivedу контракта-получателя - Правильность
approveиsetApprovalForAllлогики - Защита от transfer к
address(0)без intent to burn - Корректный подсчёт
balanceOfиownerOf
Reentrancy через ERC-721 callback
Это наиболее распространённая критическая уязвимость в NFT. safeTransferFrom вызывает onERC721Received на контракте-получателе — это внешний вызов в середине транзакции.
// УЯЗВИМЫЙ контракт
contract VulnerableNFT is ERC721 {
uint256 public mintPrice = 0.1 ether;
mapping(address => uint256) public minted;
function mint(uint256 quantity) external payable {
require(msg.value >= mintPrice * quantity, "Insufficient payment");
for (uint256 i = 0; i < quantity; i++) {
uint256 tokenId = ++_tokenCounter;
// safeTransferFrom → вызывает onERC721Received → reentrancy!
_safeMint(msg.sender, tokenId);
// State обновляется ПОСЛЕ _safeMint — уязвимо
minted[msg.sender]++;
}
}
}
Атака: злоумышленник создаёт контракт с onERC721Received, который при вызове снова вызывает mint. Если minted счётчик обновляется после _safeMint — можно превысить лимит mint per address.
// ИСПРАВЛЕННАЯ версия: Checks-Effects-Interactions
function mint(uint256 quantity) external payable nonReentrant {
require(msg.value >= mintPrice * quantity, "Insufficient payment");
require(minted[msg.sender] + quantity <= MAX_PER_WALLET, "Exceeds limit");
// Effects ПЕРВЫМИ
minted[msg.sender] += quantity;
// Interactions ПОСЛЕ
for (uint256 i = 0; i < quantity; i++) {
_safeMint(msg.sender, ++_tokenCounter);
}
}
Randomness: VRF vs blockhash
On-chain randomness для reveal — частая уязвимость. block.timestamp, blockhash, prevrandao — все манипулируемы.
// УЯЗВИМО: miner/validator может манипулировать blockhash
function revealTokens() external onlyOwner {
for (uint256 i = 1; i <= totalSupply; i++) {
uint256 randomSeed = uint256(keccak256(abi.encodePacked(
blockhash(block.number - 1),
i,
block.timestamp
)));
tokenTraits[i] = _assignTraits(randomSeed);
}
}
Validator может делать revealTokens только в блоках, где blockhash даёт выгодные traits. Это не теоретический риск — на крупных коллекциях это происходило.
Правильное решение: Chainlink VRF v2.
contract SecureNFT is VRFConsumerBaseV2 {
VRFCoordinatorV2Interface private vrfCoordinator;
uint64 private subscriptionId;
bytes32 private keyHash;
uint256 public randomWord; // получен от Chainlink
bool public revealed;
function requestReveal() external onlyOwner {
require(!revealed, "Already revealed");
vrfCoordinator.requestRandomWords(
keyHash,
subscriptionId,
3, // confirmations
100000, // callbackGasLimit
1 // numWords
);
}
function fulfillRandomWords(uint256, uint256[] memory randomWords) internal override {
randomWord = randomWords[0];
revealed = true;
emit Revealed(randomWord);
}
function getTraits(uint256 tokenId) public view returns (Traits memory) {
require(revealed, "Not revealed");
uint256 seed = uint256(keccak256(abi.encodePacked(randomWord, tokenId)));
return _assignTraits(seed);
}
}
Mint логика: whitelist, limits, timing
// Проверяем в аудите:
// 1. Merkle whitelist — корректность двойного хеширования
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(msg.sender, maxAmount))));
// НЕ: keccak256(abi.encodePacked(msg.sender)) — уязвимо к hash collision
// 2. Защита от превышения supply
require(totalSupply() + quantity <= MAX_SUPPLY, "Exceeds supply");
// Нет off-by-one ошибок?
// 3. Team mint не превышает заявленное
// Проверяем что team allocation реально ограничен
// 4. Front-running защита для whitelist
// Merkle proof не содержит amount — нельзя скопировать proof другому адресу?
// Leaf должен включать msg.sender!
Royalty механизм: ERC-2981
// Корректная реализация ERC-2981
contract NFTWithRoyalty is ERC721, ERC2981 {
constructor() {
// Устанавливаем royalty: 5% для owner
_setDefaultRoyalty(msg.sender, 500); // 500 basis points = 5%
}
// royaltyInfo должен возвращать корректные значения
// Проверяем: не превышает 100%, корректный receiver
function royaltyInfo(uint256, uint256 salePrice)
public view override returns (address receiver, uint256 royaltyAmount)
{
return super.royaltyInfo(0, salePrice);
}
// supportsInterface должен включать ERC2981
function supportsInterface(bytes4 interfaceId)
public view override(ERC721, ERC2981) returns (bool) {
return super.supportsInterface(interfaceId);
}
}
В аудите проверяем: royalty receiver не равен address(0), процент не аномально высокий, нет возможности изменить receiver без governance.
Withdraw функция
Простая, но критичная: кто может вывести ETH из контракта?
// Проверяем в аудите:
// 1. onlyOwner или multisig?
function withdraw() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
// 2. Нет ли pull payment уязвимостей?
// 3. ETH не может застрять в контракте при неудачном transfer?
// Используй call вместо transfer:
(bool success, ) = payable(owner()).call{value: address(this).balance}("");
require(success, "Transfer failed");
// 4. Нет ли возможности drain перед reveal/sale через exploit?
Типичные находки по severity
| Severity | Пример уязвимости | Частота |
|---|---|---|
| Critical | Reentrancy в mint позволяет mint сверх supply | Редко |
| High | Манипулируемый randomness для reveal | Часто |
| High | Merkle leaf без msg.sender — proof можно украсть | Средне |
| Medium | Withdraw без nonReentrant | Часто |
| Medium | totalSupply overflow при большом количестве mint | Редко |
| Low | Missing events для critical functions | Очень часто |
| Informational | Gas оптимизация (ERC721A vs ERC721) | Всегда |
ERC-721A: аудит специфика
Многие коллекции используют ERC-721A (Azuki's оптимизированная версия для batch mint). Специфические проверки:
- Корректность
_startTokenId()— по умолчанию 0, но некоторые проекты хотят с 1 -
_nextTokenId()корректно инициализирован - Batch transfer не нарушает ownership маппинг
-
tokensOfOwnerне exceeds gas limit для cold wallets с большим количеством токенов
Процесс аудита
Статический анализ. Запуск Slither — автоматическое обнаружение стандартных паттернов (reentrancy, integer overflow, unprotected functions). Не заменяет ручной аудит, но отсеивает базовые проблемы.
slither contracts/NFTCollection.sol \
--solc-remaps "@openzeppelin=node_modules/@openzeppelin" \
--checklist \
--markdown-root .
Ручной review. Проверка business logic: соответствует ли код whitepaper и заявленной механике. Автоматика не поймёт, что maxPerWallet = 100 при totalSupply = 10000 нарушает заявленную "fair launch" механику.
Fuzz testing. Foundry fuzzing для mint логики и edge cases:
function testFuzz_MintDoesNotExceedSupply(uint256 quantity) public {
quantity = bound(quantity, 1, 100);
vm.deal(user, 100 ether);
vm.prank(user);
nft.mint{value: 0.1 ether * quantity}(quantity);
assertLe(nft.totalSupply(), nft.MAX_SUPPLY());
}
Срок аудита NFT-контракта: 1–2 недели для стандартной коллекции. С кастомной механикой (staking, breeding, on-chain game logic) — 2–4 недели.







