
Хабр, привет!
На связи Леонид Дьячков, в Positive Technologies мы с командой специализируемся на безопасности смарт-контрактов и блокчейн-приложений. Наша экспертиза охватывает полный спектр задач: от глубокого криптоанализа и проектирования кастомных фаззинг-кампаний до разработки и применения формальных методов верификации. Мы работаем на стыке кода, математики и бизнес-логики Web3-протоколов, выявляя неочевидные векторы атак и системные риски.
Обычно в отрасли много внимания уделяется экосистеме EVM (Ethereum Virtual Machine, виртуальная машина Ethereum), но в этой статье мы целенаправленно разбираем приватный блокчейн Hyperledger Fabric, потому что он важен для корпоративных сценариев, так как позволяет допускать к сети только авторизованных участников, управлять их идентификацией и изолировать данные на уровне каналов.
Hyperledger Fabric официально описывается как разрешенная корпоративная платформа DLT (distributed ledger technology, технология распределенного реестра), и это не только формулировка из документации. В экосистеме есть публично описанные production-кейсы корпоративного уровня (например, openIDL — страховой отраслевой контур обмена данными, GSBN — платформа для цифровизации глобальной морской логистики, taXchain — налогово-таможенный контур обмена данными). Поэтому для практики безопасности важно иметь отдельную методику тестирования именно Fabric-контрактов, а не переносить подходы из EVM.
Фокус статьи — проект ACL. На его примере мы покажем, как применять фаззинг к chaincode (смарт-контрактам в терминологии Fabric), формулировать инварианты и строить воспроизводимый процесс запусков и анализа покрытия. Отдельно разберем, чем такой фаззинг отличается от фаззинга в EVM-блокчейнах: где совпадает общая идея инвариантов, а где различаются модель вызовов, состояние и практические точки отказа.
Фаззинг — это метод динамического тестирования, при котором целевые методы автоматически вызываются на большом количестве вариативных входов, а корректность проверяется через оракулы (инварианты, ожидаемые классы ошибок, ограничения на изменения состояния). В аудите мы используем его как отдельный этап после ручного анализа кода, базовых unit- и integration-проверок: сначала формализуем инварианты, затем выполняем длительные прогоны и по найденным входам пополняем регрессионный набор тестов.
Подробнее о Hyperledger Fabric
Hyperledger Fabric — это permissioned-блокчейн-сеть (сеть с разрешенным доступом) с разделением ролей и сервисов (обзор chaincode).
В permissioned-блокчейне участники сети не анонимны: доступ выдается через управляемую систему идентификации и доверия (сертификаты, политики, роли). На практике это означает, что в модель безопасности входят не только ошибки бизнес-логики контракта, но и ошибки разграничения доступа, проверки полномочий и изоляции данных между разными группами участников:
peer-узлы выполняют chaincode и формируют read- и write-наборы;
служба ordering service упорядочивает транзакции;
MSP (Membership Service Provider, сервис управления участниками) и политики определяют, кто и что может подписывать;
каналы и private data collections (приватные коллекции данных) изолируют данные.
Транзакционный поток в Fabric — execute → endorse → order → validate (подробнее: hyperledger-fabric.readthedocs.io/en/latest/txflow.html). Для chaincode это означает одну важную вещь — поведение должно быть детерминированным. Один и тот же вход на разных узлах-эндорсерах (endorsing peers) должен приводить к одинаковому результату и read- или write-набору. Любые «скользкие» ветки обработки входа, зависимость от неочевидного состояния или неконсистентная сериализация могут сломать подтверждение транзакций.
Именно поэтому фаззинг в Fabric особенно полезен в трех зонах:
Авторизация и права доступа.
Криптографические и сериализационные переходы (ключи, подписи, форматы, nonce как одноразовый параметр запроса).
Корректность переходов состояния при ошибках.
Fabric vs. EVM в контексте фаззинга
На уровне техники подходы cхожи: задаём seed-входы, проверяем инварианты, запускаем мутации с обратной связью по покрытию и переносим найденные входы в корпус для регресса.
Разница в том, где находится центр тяжести:
в EVM-проектах часто используют специализированные фреймворки, например Echidna, где тестирование строится вокруг property-based (свойство-ориентированных) инвариантов и последовательностей вызовов контрактов;
в Fabric-chaincode чаще опираются на встроенный Go fuzzing (
testing.F) и прикладную обвязку проекта;в Go это запускается как обычный исполняемый тестовый бинарник;
тестовым оракулом здесь выступают не аварийные завершения и недостижимые состояния, а бизнес-инварианты, продиктованные логикой смарт-контракта;
в Fabric также важны permissioned-аспекты: политики доступа, иденти��ности участников, детерминированность эндорсмента и изоляция данных по каналам и коллекциям.
Поэтому внешне фаззинг chaincode действительно похож на фаззинг обычных Go-сервисов, но по проверяемым свойствам это тот же класс задач, что и в фаззинге смарт-контрактов EVM.
Выбор цели для фаззинга
ACL — отдельный chaincode, который хранит данные пользователей, статусы и матрицу прав. Он используется не изолированно: другие контракты и сервисные слои опираются на ответы ACL при авторизации.
Это видно в коде интеграции:
// foundation/core/helpers/acl.go resp := stub.InvokeChaincode(config.CcACL, [][]byte{ []byte(FnCheckKeys), []byte(keys), }, config.ACLChannelName) // foundation/core/cc_auth.go if acl.GetAccount().GetBlackListed() { return nil, fmt.Errorf("address %s is blacklisted", (*types.Address)(acl.GetAddress().GetAddress()).String()) } if acl.GetAccount().GetGrayListed() { return nil, fmt.Errorf("address %s is graylisted", (*types.Address)(acl.GetAddress().GetAddress()).String()) }
И в интеграционных тестах ACL этот контур используется явно:
// acl/tests/integration/basic/basic_test.go By("add admin to acl") ts.AddAdminToACL() By("adding old user to ACL") ts.AddUser(user) By("checking address") ts.Query(cmn.ChannelACL, cmn.ChannelACL, FnCheckAddress, user.AddressBase58Check)
Ошибка в ACL обычно влияет не только на сам контракт, но и на сценарии, которые используют его ответы при проверке доступа.
Что именно делает ACL с точки зрения фаззинг-целей
По API и коду ACL зоны риска логично раскладываются так:
онбординг пользователя:
AddUser,AddUserWithPublicKeyType;ротация ключей и подписи:
ChangePublicKey,ChangePublicKeyWithBase58Signature;права доступа:
AddRights,RemoveRights,GetAccountOperationRight;KYC и списки:
Setkyc,AddToList,DelFromList;дополнительные ключи и multisig:
AddAdditionalKey,RemoveAdditionalKey,AddMultisig;query-методы:
GetUser,GetAccountAllRights,GetOperationAllRights,CheckKeys.
Для write-методов базовый инвариант один — при отказе не должно быть побочных изменений в состоянии (в глобальном key-value хранилище chaincode). Для query-методов состояние должно оставаться неизменным всегда.
Для ACL удобно фиксировать каждое свойство в форме: предусловие → ожидаемое поведение → оракул (assertion в тесте).
Вместо полного перечня приведем два характерных примера:
Запросы read-only (
GetUser,CheckKeysи другие) не должны менять состояние: делаем снимки до и после, а затем сравниваем их через require.Equal(...).Привилегированные write-методы (
AddRights,AddUser) без прав администратора должны завершаться отказом и не менять состояние: проверяем класс ошибки иvalidateStateChanges(..., []string{}).
Откуда берется методология
Раздел опирается на идеи HFContractFuzzer (arXiv:2106.11210), но адаптирован под текущий стек ACL.
В прикладном процессе это превращается в такой цикл:
Подготовить корректные правила локального вызова и базовые unit-кейсы.
Построить стартовый корпус из доменных кейсов и граничных значений.
Запустить фаззинг с обратной связью по покрытию и зафиксировать входы, которые приводят к сбоям или аномалиям.
Подтвердить находки отдельными воспроизводимыми тестами, доработать корпус и оракулы.
Корпус входов в Go-фаззинге ACL
Фаззинг-тест в Go строится на testing.F: сначала задаем seed-входы через f.Add(...), дальше движок мутирует их и сохраняет удачные варианты в файловый корпус.
Для ACL это означает три рабочих источника входов:
Seed-значения в коде таргета
(f.Add(...)).Файловый корпус в
tests/fuzz/testdata/fuzz/<FuzzName>.Рабочий кэш движка фаззинга Go в
GOCACHE/fuzz.
Базовая практика, которую используем для ACL:
Дать несколько заведомо валидных входов, чтобы быстро пройти глубже в логику.
Добавить граничные и почти валидные варианты.
Отсечь явно нецелевой мусор.
Проверить инварианты состояния, а не ограничиваться оракулом «нет паники».
Пример seed-набора из ACL:
f.Add("channel", "chaincode", "role", "op", common.TestAddr, admin) f.Add("", "", "", "", "", admin) f.Add("ch", "cc", "role", "op", strings.Repeat("x", 100), admin)
Инвентаризация тестов
В работе используем два этапа: планирование до прогонов и анализ после прогонов.
До запусков формируем список кандидатов:
go run ./scripts/analyze/analyze_fuzz_candidates.go -dir . -output tests/fuzz/docs/fuzz_candidates.md
Скрипт обходит Go-файлы (кроме *_test.go), отбирает экспортируемые функции с входными параметрами и присваивает приоритет по типам входов (High, Medium или Low).
Выполняем фаззинг-прогоны.
После прогонов фиксируем срез текущего покрытия:
go run ./scripts/generate_fuzz_inventory.go > tests/fuzz/docs/COVERAGE.md
generate_fuzz_inventory — отчетный скрипт: он сопоставляет существующие Fuzz* с таргетами и подтягивает фактические метрики из function_coverage.txt и coverage_summary.txt.
По этому отчету принимаем изменения: решаем, где добавить новый таргет, где усилить инварианты, где доработать seed-корпус.
Как перевести обычный тест в фаззинг-тест
Обычно стартуем с существующего Test*: переносим кейсы table-driven в f.Add(...), затем ту же проверку выполняем внутри f.Fuzz(...).
Короткий шаблон:
func FuzzAddRights(f testing.F) { for _, tc := range baseCases { f.Add(tc.ch, tc.cc, tc.role, tc.op, tc.addr, tc.withAdmin) } f.Fuzz(func(t testing.T, ch, cc, role, op, addr string, withAdmin bool) { stub, beforeState := newFuzzStub(t) err := acl.AddRights(stub, []string{ch, cc, role, op, addr}) if !withAdmin { require.Error(t, err) validateStateChanges(t, beforeState, stub.GetStateCallsMap, []string{}) } }) }
Как устроен фаззинг ACL
Фаззинг-тесты в ACL построены вокруг MockStub (тестовой реализации chaincode stub) и набора вспомогательных функций:
newFuzzStub— поднимает mock stub и in-memory-состояние;createComprehensiveTestState,defaultQueryState— готовят базовое состояние;copyState— делает снимок состояния до вызова;validateStateChanges— проверяет, что изменились только ожидаемые ключи в состоянии;noPanic— делает панику явным падением теста;validateError— нормализует разрешенные классы ошибок.
Такой каркас уменьшает шум в результатах и делает инварианты воспроизводимыми.
Пример 1: AddRights и контроль побочных изменений
AddRights — хороший пример метода, где важно одновременно проверять:
авторизацию;
валидацию входов;
корректные изменения состояния только по ожидаемым ключам.
Фрагмент фаззинг-теста:
// acl/tests/fuzz/fuzz_acl_rights_modify_test.go err := acl.AddRights(stub, []string{ch, ccName, role, op, addr}) if !withAdmin { require.Error(t, err) require.True(t, validateError(err, []string{"unauthorized", "forbidden", "access denied"})) validateStateChanges(t, beforeState, state, []string{}) return } if err == nil { validateStateChanges(t, beforeState, state, []string{"rights"}) } else { require.True(t, validateError(err, []string{"failed", "invalid", "address", "empty", "duplicate"})) validateStateChanges(t, beforeState, state, []string{}) }
Практический смысл такого оракула: мы ловим не только баги «доступа без прав», но и тихую порчу состояния при ошибочных вызовах.
Пример 2: AddUserWithPublicKeyType
В AddUserWithPublicKeyType сходятся сразу несколько чувствительных осей:
формат публичного ключа;
тип ключа;
требования к роли вызывающего;
консистентность записей пользователя.
Фаззер проверяет, что:
без прав администратора — всегда отказ;
при успехе пишутся ожидаемые ключи (public_key, signed_address, account_info);
при ошибке нет несанкционированных модификаций состояния.
Этот таргет хорошо показывает, что для ACL важнее доменные seed-входы, чем полностью случайные данные.
Пример 3: AddAdditionalKey (подписи, labels, nonce)
Блок про AdditionalKey остается важным, потому что здесь сходятся проверка подписи, формат labels и nonce (одноразовый параметр запроса).
// acl/tests/fuzz/fuzz_acl_additional_keys_test.go err := acl.AddAdditionalKey(stub, []string{addr, key, labels, nonce, vkey, vsig}) if !withAdmin && err != nil { require.True(t, containsAny(err.Error(), []string{"unauthorized", "failed", "invalid", "address", "empty", "key"})) return } if err != nil { require.True(t, strings.Contains(strings.ToLower(err.Error()), "failed") strings.Contains(strings.ToLower(err.Error()), "invalid") strings.Contains(strings.ToLower(err.Error()), "address") strings.Contains(strings.ToLower(err.Error()), "key") strings.Contains(strings.ToLower(err.Error()), "labels")) }
Что это дает на практике:
проверяем, что невалидные подписи и входы отсекаются предсказуемо;
фиксируем ожидаемые классы ошибок для регрессионного контроля;
отдельно видим зону усиления: добавить для
Add/RemoveAdditionalKeyтакой же явный контроль побочных изменений состояния, как вAddRights.
Корпус входных данных
Имеет смысл в качестве начальных seed-значений добавить входные данные следующих видов:
Валидные рабочие входы, которые гарантированно проходят вглубь логики.
Почти валидные входы: длина, формат, частично корректные подписи.
Явно невалидные комбинации (пустые поля, мусор, oversize-строки).
Дубли и повторные вызовы для проверки идемпотентности или дедупликации.
Если сразу «кормить» фаззер только случайным шумом, он большую часть времени тратит на поверхностные отказы и почти не трогает критически важные ветки.
Запуск фаззинга
На практике для регулярных прогонов мы вызываем вспомогательный скрипт run_fuzz.sh. Если немного упростить, то его основная логика сводится к следующему фрагменту:
# 1) Discover targets TARGETS=$(go test ./tests/fuzz -list ^Fuzz | grep ^Fuzz) # 2) Run each target with isolated cache for fuzztest in $TARGETS; do GOCACHE="$FUZZCACHE/${fuzztest}" go test ./tests/fuzz \ -run "^${fuzztest}$" \ -fuzz "^${fuzztest}$" \ -fuzztime "$FUZZTIME" \ done
Ключевые детали:
каждый таргет запускается отдельно (проще локализовать падения);
для каждого тарге��а — отдельный GOCACHE (корпус и крэши не смешиваются);
FUZZSELECT позволяет запускать подмножество таргетов по regex (регулярному выражению).
Go 1.24+: покрытие через GOCOVERDIR
Ключевой практический момент: для режима фаззинга удобнее и стабильнее работать через GOCOVERDIR (переменную окружения для записи coverage-данных) и go tool covdata (утилиту агрегации и конвертации coverage-данных), а не пытаться строить процесс вокруг старого -coverprofile.
В рантайме цепочка выглядит так:
export GOCOVERDIR=tests/fuzz/results # ... фаззинг-прогон ... go tool covdata textfmt -i tests/fuzz/results -o tests/fuzz/results/coverage.out go tool cover -func tests/fuzz/results/coverage.out
Когда нужен LCOV
Если отчетность уходит во внешние системы, покрытие обычно удобнее переводить в LCOV-формат (текстовый .info-формат из экосистемы gcov/lcov, где gcov — утилита анализа покрытия из GCC).
Для конвертации используем gcov2lcov — внешнюю утилиту (не часть стандартных инструментов Go), которая преобразует Go coverage.out в LCOV .info.
Алгоритм работы кратко:
читает профиль
coverage.out;разбирает блоки покрытия и счетчики выполнений;
агрегирует данные в структуру LCOV по файлам, строкам и функциям;
формирует выходной
.info-файл с записями LCOV.
gcov2lcov -infile=coverage.out -outfile=coverage.lcov
В LCOV-файле хранятся счетчики покрытия по строкам, функциям и ветвлениям (например, записи DA, FN/FNDA, BRDA). Дальше LCOV удобно визуализировать через genhtml (HTML-генератор отчетов LCOV). Такие отчеты обычно информативнее обычной сводки, потому что показывают не только факт покрытия, но и количество выполнений для каждой строки кода.
Результаты
В основной серии прогонов ACL критически опасных дефектов не было. Поэтому ниже — три уязвимых сценария на основе стандартного токена из проекта Foundation. Они показывают, как могут выглядеть реальные проблемы в коде.
1. Ошибки округления при распределении наград
Инвариант: сумма начислений должна равняться общей награде (sum(credited) == totalReward).
Фрагмент контракта:
func (vt VulnerableToken) TxDistributeRewards( sender types.Sender, recipients []string, shares []uint64, totalReward *big.Int, ) error { if len(recipients) != len(shares) { return errors.New("несовпадение длины recipients и shares") } var totalShares uint64 for _, share := range shares { totalShares += share } if totalShares == 0 { return errors.New("общая сумма долей равна нулю") } for i, recipientStr := range recipients { recipient, err := types.AddrFromBase58Check(recipientStr) if err != nil { return err } // Целочисленное деление с округлением вниз. reward := new(big.Int).Mul(totalReward, new(big.Int).SetUint64(shares[i])) reward = reward.Div(reward, new(big.Int).SetUint64(totalShares)) if err := vt.TokenBalanceAdd(recipient, reward, "reward distribution"); err != nil { return err } } return nil }
Фрагмент фаззинг-оракула:
recipients := []string{ ctx.recipientPool[0].AddressBase58Check, ctx.recipientPool[1].AddressBase58Check, ctx.recipientPool[2].AddressBase58Check, } shares := []uint64{share1, share2, share3} recipientsJSON, err := json.Marshal(recipients) require.NoError(t, err) sharesJSON, err := json.Marshal(shares) require.NoError(t, err) _, resp := mockStub.TxInvokeChaincodeSigned( cc, "distributeRewards", ctx.admin, "", "", "", string(recipientsJSON), string(sharesJSON), new(big.Int).SetUint64(totalReward).String(), ) if resp.GetStatus() != 200 { return } totalDistributed := sumRecipientWrites(mockStub, recipientKeys(t, mockStub, recipients)) loss := new(big.Int).Sub(new(big.Int).SetUint64(totalReward), totalDistributed) if loss.Cmp(big.NewInt(0)) > 0 { t.Fatalf("distribution must conserve tokens (sum credited == totalReward)") }
Воспроизведение:
go test -run=^$ -fuzz=^FuzzDistributeRewards$ -fuzztime=2s -v --- FAIL: FuzzDistributeRewards distribution must conserve tokens (sum credited == totalReward)
2. Переполнение суммы долей
Инвариант: если sum(shares) переполняет uint64, транзакция должна быть отклонена.
Фрагмент фаззинг-теста:
func FuzzDistributeRewards_OverflowMustReject(f testing.F) { f.Add(uint8(3), uint64(math.MaxUint64)) f.Add(uint8(30), uint64(math.MaxUint64)) f.Add(uint8(10), uint64(math.MaxUint64/2+1)) f.Fuzz(func(t testing.T, numRecipients uint8, share uint64) { ctx := mustInitFuzzCtx(t) n := uint64(numRecipients) if share == 0 numRecipients < 2 numRecipients > 30 { t.Skip() return } sumWouldOverflow := share > math.MaxUint64/n if !sumWouldOverflow { t.Skip() return } totalReward := uint64(1) mockStub, cc := setupDistributeRewardsChaincode(t, ctx, totalReward) recipients := make([]string, 0, int(numRecipients)) shares := make([]uint64, 0, int(numRecipients)) for i := 0; i < int(numRecipients); i++ { recipients = append(recipients, ctx.recipientPool[i].AddressBase58Check) shares = append(shares, share) } recipientsJSON, err := json.Marshal(recipients) require.NoError(t, err) sharesJSON, err := json.Marshal(shares) require.NoError(t, err) _, resp := mockStub.TxInvokeChaincodeSigned( cc, "distributeRewards", ctx.admin, "", "", "", string(recipientsJSON), string(sharesJSON), new(big.Int).SetUint64(1).String(), ) if resp.GetStatus() == 200 { t.Fatalf("sum(shares) overflow must be rejected; tx succeeded: n=%d share=%d", numRecipients, share) } }) }
Воспроизведение:
go test -run=^$ -fuzz=^FuzzDistributeRewards_OverflowMustReject$ -fuzztime=2s -v --- FAIL: FuzzDistributeRewards_OverflowMustReject sum(shares) overflow must be rejected; tx succeeded: n=3 share=18446744073709551615
3. Потери при большом числе получателей
Инвариант тот же: сумма начислений должна совпадать с totalReward даже при увеличении числа получателей.
Фрагмент фаззинг-теста:
func FuzzDistributeRewards_ManyRecipients(f testing.F) { f.Add(uint64(10000), uint8(5)) f.Add(uint64(1000000), uint8(7)) f.Add(uint64(999), uint8(10)) f.Add(uint64(100), uint8(33)) f.Fuzz(func(t testing.T, totalReward uint64, numRecipients uint8) { ctx := mustInitFuzzCtx(t) if totalReward == 0 numRecipients < 2 numRecipients > 30 { t.Skip() return } mockStub, cc := setupDistributeRewardsChaincode(t, ctx, totalReward) recipients := make([]string, 0, int(numRecipients)) shares := make([]uint64, 0, int(numRecipients)) for i := 0; i < int(numRecipients); i++ { recipients = append(recipients, ctx.recipientPool[i].AddressBase58Check) shares = append(shares, 1) } recipientsJSON, err := json.Marshal(recipients) require.NoError(t, err) sharesJSON, err := json.Marshal(shares) require.NoError(t, err) _, resp := mockStub.TxInvokeChaincodeSigned( cc, "distributeRewards", ctx.admin, "", "", "", string(recipientsJSON), string(sharesJSON), new(big.Int).SetUint64(totalReward).String(), ) if resp.GetStatus() != 200 { return } totalDistributed := sumRecipientWrites(mockStub, recipientKeys(t, mockStub, recipients)) loss := new(big.Int).Sub(new(big.Int).SetUint64(totalReward), totalDistributed) if loss.Cmp(big.NewInt(0)) > 0 { t.Errorf("Token loss detected: %s tokens", loss.String()) } }) }
Воспроизведение:
go test -run=^$ -fuzz=^FuzzDistributeRewards_ManyRecipients$ -fuzztime=2s -v --- FAIL: FuzzDistributeRewards_ManyRecipients Token loss detected: 1 tokens
Заключение
Технически фаззинг chaincode во многом выглядит как фаззинг обычных приложений: testing.F, seed-корпус, мутации с обратной связью по покрытию и обычный исполняемый Go-тестовый бинарник. Но по духу это все равно фаззинг смарт-контрактов: тестовым оракулом здесь служат не аварийные завершения и недостижимые состояния, а бизнес-инварианты, обусловленные логикой функционирования контракта.
Разница скорее в инструментарии и модели исполнения. Для EVM есть специализированные решения вроде Echidna (property-based, то есть свойство-ориентированные проверки инвариантов на последовательностях вызовов), а в Hyperledger Fabric приходится использовать стандартные инструменты фаззинга в Go.
Ссылки на документацию по Hyperledger Fabric и упомянутым в статье инструментам
go tool covdata: pkg.go.dev/cmd/covdatLCOV (Linux Test Project) (github)
gcov2lcov: github.com/jandelgado/gcov2lcovEchidna (property-based fuzzer for EVM smart contracts) (github)
HFContractFuzzer (arXiv)
