Хабр, привет!

На связи Леонид Дьячков, в 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 особенно полезен в трех зонах:

  1. Авторизация и права доступа.

  2. Криптографические и сериализационные переходы (ключи, подписи, форматы, nonce как одноразовый параметр запроса).

  3. Корректность переходов состояния при ошибках.

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.

В прикладном процессе это превращается в такой цикл:

  1. Подготовить корректные правила локального вызова и базовые unit-кейсы.

  2. Построить стартовый корпус из доменных кейсов и граничных значений.

  3. Запустить фаззинг с обратной связью по покрытию и зафиксировать входы, которые приводят к сбоям или аномалиям.

  4. Подтвердить находки отдельными воспроизводимыми тестами, доработать корпус и оракулы.

Корпус входов в Go-фаззинге ACL

Фаззинг-тест в Go строится на testing.F: сначала задаем seed-входы через f.Add(...), дальше движок мутирует их и сохраняет удачные варианты в файловый корпус.

Для ACL это означает три рабочих источника входов:

  1. Seed-значения в коде таргета (f.Add(...)).

  2. Файловый корпус в tests/fuzz/testdata/fuzz/<FuzzName>.

  3. Рабочий кэш движка фаззинга Go в GOCACHE/fuzz.

Базовая практика, которую используем для ACL:

  1. Дать несколько заведомо валидных входов, чтобы быстро пройти глубже в логику.

  2. Добавить граничные и почти валидные варианты.

  3. Отсечь явно нецелевой мусор.

  4. Проверить инварианты состояния, а не ограничиваться оракулом «нет паники».

Пример 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 и упомянутым в статье инструментам