понедельник, 12 сентября 2022 г.

Начало работы с фаззингом в Golang

В этом руководстве представлены основы фаззинга в Go. При фаззинге случайные данные обрабатываются для вашего теста в попытке найти уязвимости или входные данные, вызывающие сбой. Некоторыми примерами уязвимостей, которые можно найти с помощью фаззинга, являются SQL-инъекции, переполнение буфера, отказ в обслуживании и атаки с использованием межсайтовых сценариев.

В этом руководстве вы напишете fuzz тест для простой функции, запустите команду go, отладите и исправите проблемы в коде.

Предпосылки

  • Установленный Go 1.18 или более поздней версии.
  • Инструмент для редактирования вашего кода. Любой текстовый редактор, который у вас есть, будет работать нормально.
  • Командный терминал. Go хорошо работает с любым терминалом в Linux и Mac, а также с PowerShell или cmd в Windows.
  • Среда, поддерживающая фаззинг. В настоящее время фаззинг с инструментами покрытия доступен только для архитектур AMD64 и ARM64.

Создайте папку для своего кода

Для начала создайте папку для кода, который вы будете писать.

1. Откройте командную строку и перейдите в свой домашний каталог.

В Linux или Mac:

$ cd

В Windows:

C:\> cd %HOMEPATH%

В остальной части руководства в качестве подсказки будет отображаться символ $. Используемые вами команды будут работать и в Windows.

2. В командной строке создайте каталог для своего кода с именем fuzz.

$ mkdir fuzz
$ cd fuzz

3. Создайте модуль для хранения вашего кода.

Запустите команду go mod init, указав путь к модулю вашего нового кода.

$ go mod init example/fuzz
go: creating new go.mod: module example/fuzz

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

Далее вы добавите простой код для переворачивания строки, который мы позже фаззим.

Добавьте код для тестирования

На этом шаге вы добавите функцию для обращения строки.

Напишите код

1. Используя текстовый редактор, создайте файл с именем main.go в каталоге fuzz.

2. В файл main.go в верхней части файла вставьте следующее объявление пакета.

package main

Автономная программа (в отличие от библиотеки) всегда находится в пакете main.

3. Под объявлением пакета вставьте следующее объявление функции.

func Reverse(s string) string {
    b := []byte(s)
    for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
        b[i], b[j] = b[j], b[i]
    }
    return string(b)
}

Эта функция примет строку, обработает ее побайтно и в конце вернет перевернутую строку.

Примечание. Этот код основан на функции stringutil.Reverse из golang.org/x/example.

4. В верхней части main.go, под объявлением пакета, вставьте следующую main функцию, чтобы инициализировать строку, перевернуть ее, распечатать вывод и повторить.

func main() {
    input := "The quick brown fox jumped over the lazy dog"
    rev := Reverse(input)
    doubleRev := Reverse(rev)
    fmt.Printf("original: %q\n", input)
    fmt.Printf("reversed: %q\n", rev)
    fmt.Printf("reversed again: %q\n", doubleRev)
}

Эта функция выполнит несколько Reverse операций, а затем выведет вывод в командную строку. Это может быть полезно для просмотра кода в действии и для отладки.

5. main функция использует пакет fmt, поэтому вам нужно будет его импортировать.

Первые строки кода должны выглядеть так:

package main

import "fmt"

Запустите код

Из командной строки в каталоге, содержащем main.go, запустите код.

$ go run .
original: "The quick brown fox jumped over the lazy dog"
reversed: "god yzal eht revo depmuj xof nworb kciuq ehT"
reversed again: "The quick brown fox jumped over the lazy dog"

Вы можете увидеть исходную строку, результат ее обращения, затем результат повторного обращения, который эквивалентен оригиналу.

Теперь, когда код работает, пришло время его протестировать.

Добавить модульный (юнит) тест

На этом шаге вы напишете базовый модульный тест для функции Reverse.

Напишите код

1. Используя текстовый редактор, создайте файл с именем reverse_test.go в каталоге fuzz.

2. Вставьте следующий код в reverse_test.go.

package main

import (
    "testing"
)

func TestReverse(t *testing.T) {
    testcases := []struct {
        in, want string
    }{
        {"Hello, world", "dlrow ,olleH"},
        {" ", " "},
        {"!12345", "54321!"},
    }
    for _, tc := range testcases {
        rev := Reverse(tc.in)
        if rev != tc.want {
                t.Errorf("Reverse: %q, want %q", rev, tc.want)
        }
    }
}

Этот простой тест подтвердит, что перечисленные входные строки будут правильно инвертированы.

Запустите код

Запустите модульный тест, используя go test

$ go test
PASS
ok      example/fuzz  0.013s

Далее вы измените модульный тест на fuzz тест.

Добавить fuzz тест

Модульный тест имеет ограничения, а именно то, что каждый вход должен быть добавлен в тест разработчиком. Одним из преимуществ фаззинга является то, что он предоставляет входные данные для вашего кода и может выявить крайние случаи, которых не достигли созданные вами тестовые случаи.

В этом разделе вы преобразуете модульный тест в fuzz тест, чтобы вы могли генерировать больше входных данных с меньшими затратами!

Обратите внимание, что вы можете хранить модульные тесты, бенчмарки и fuzz тесты в одном и том же файле *_test.go, но в этом примере вы преобразуете модульный тест в fuzz тест.

Напишите код

В текстовом редакторе замените модульный тест в reverse_test.go следующим fuzz тестом.

func FuzzReverse(f *testing.F) {
    testcases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc)  // Используйте f.Add для предоставления начального корпуса (seed corpus)
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev := Reverse(orig)
        doubleRev := Reverse(rev)
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

У фаззинга также есть несколько ограничений. В своем модульном тесте вы можете предсказать ожидаемый результат функции Reverse и убедиться, что фактический результат соответствует этим ожиданиям.

Например, в тестовом случае Reverse("Hello, world") модульный тест определяет результат как "dlrow,olleH".

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

Однако есть несколько свойств функции Reverse, которые вы можете проверить с помощью fuzz теста. В этом fuzz тесте проверяются два свойства:

  • Двойное обращение строки сохраняет исходное значение
  • Перевернутая строка сохраняет свое состояние как действительное UTF-8.

Обратите внимание на синтаксические различия между модульным тестом и fuzz тестом:

  • Функция начинается с FuzzXxx вместо TestXxx и принимает *testing.F вместо *testing.T.
  • Там, где вы ожидаете увидеть выполнение t.Run, вместо этого вы видите f.Fuzz, который принимает целевую функцию фаззинга с параметрами *testing.T и типами, подлежащими фаззингу. Входные данные из вашего модульного теста предоставляются как входные данные начального корпуса с помощью f.Add.

Убедитесь, что новый пакет unicode/utf8 импортирован.

package main

import (
    "testing"
    "unicode/utf8"
)

Когда модульный тест преобразован в fuzz тест, пришло время снова запустить тест.

Запустите код

1. Запустите fuzz тест без фаззинга, чтобы убедиться, что исходные входные данные пройдены.

$ go test
PASS
ok      example/fuzz  0.013s

Вы также можете запустить go test -run=FuzzReverse, если у вас есть другие тесты в этом файле, и вы хотите запустить только fuzz тест.

2. Запустите FuzzReverse с фаззингом, чтобы увидеть, не приведут ли какие-либо случайно сгенерированные вводы строк к сбою. Это выполняется с помощью go test с новым флагом -fuzz.

$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers
fuzz: minimizing 38-byte failing input file...
--- FAIL: FuzzReverse (0.01s)
    --- FAIL: FuzzReverse (0.00s)
        reverse_test.go:20: Reverse produced invalid UTF-8 string "\x9c\xdd"

    Failing input written to testdata/fuzz/FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
    To re-run:
    go test -run=FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
FAIL
exit status 1
FAIL    example/fuzz  0.030s

Во время фаззинга произошел сбой, и ввод, вызвавший проблему, записывается в файл начального корпуса (seed corpus), который будет запущен при следующем вызове go test, даже без флага -fuzz. Чтобы просмотреть ввод, вызвавший сбой, откройте файл корпуса, записанный в каталог testdata/fuzz/FuzzReverse, в текстовом редакторе. Ваш файл исходного корпуса может содержать другую строку, но формат будет таким же.

go test fuzz v1
string("泃")

Первая строка файла корпуса указывает версию кодировки. Каждая следующая строка представляет значение каждого типа, составляющего запись корпуса. Поскольку цель фаззинга принимает только 1 вход, после версии остается только 1 значение.

3. Снова запустите go test без флага -fuzz; будет использоваться новая ошибочная запись корпуса исходных данных:

$ go test
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a (0.00s)
        reverse_test.go:20: Reverse produced invalid string
FAIL
exit status 1
FAIL    example/fuzz  0.016s

Поскольку наш тест провалился, пришло время отладки.

Исправить ошибку неверной строки

В этом разделе вы отладите сбой и исправите ошибку.

Диагностируйте ошибку

Есть несколько различных способов отладки этой ошибки. Если вы используете VS Code в качестве текстового редактора, вы можете настроить отладчик для исследования.

В этом руководстве мы будем регистрировать полезную информацию об отладке на вашем терминале.

Во-первых, рассмотрим документацию для utf8.ValidString.

ValidString сообщает, состоит ли s полностью из допустимых рун в кодировке UTF-8.

Текущая функция Reverse переворачивает строку байт за байтом, и в этом наша проблема. Чтобы сохранить руны исходной строки в кодировке UTF-8, мы должны вместо этого перевернуть строку руна за руной.

Чтобы выяснить, почему ввод (в данном случае китайский иероглиф 泃) приводит к тому, что Reverse создает недопустимую строку при переворачивании, вы можете проверить количество рун в перевернутой строке.

Напишите код

В текстовом редакторе замените цель fuzz в FuzzReverse следующим.

f.Fuzz(func(t *testing.T, orig string) {
    rev := Reverse(orig)
    doubleRev := Reverse(rev)
    t.Logf("Number of runes: orig=%d, rev=%d, doubleRev=%d", utf8.RuneCountInString(orig), utf8.RuneCountInString(rev), utf8.RuneCountInString(doubleRev))
    if orig != doubleRev {
        t.Errorf("Before: %q, after: %q", orig, doubleRev)
    }
    if utf8.ValidString(orig) && !utf8.ValidString(rev) {
        t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
    }
})

Здесь строка t.Logf будет выводиться в командную строку в случае возникновения ошибки или при выполнении теста с ключом -v, что может помочь вам отладить эту конкретную проблему.

Запустите код

Запустите тест, используя go test

$ go test
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s)
        reverse_test.go:16: Number of runes: orig=1, rev=3, doubleRev=1
        reverse_test.go:21: Reverse produced invalid UTF-8 string "\x83\xb3\xe6"
FAIL
exit status 1
FAIL    example/fuzz    0.598s

Во всем начальном корпусе (seed corpus) использовались строки, в которых каждый символ представлял собой один байт. Однако для таких символов, как 泃, может потребоваться несколько байтов. Таким образом, обращение строки байт за байтом сделает недействительными многобайтовые символы.

Примечание. Если вам интересно, как Go работает со строками, прочтите запись в блоге Строки, байты, руны и символы в Go для более глубокого понимания.

Лучше поняв ошибку, исправьте ошибку в функции Reverse.

Исправьте ошибку

Чтобы исправить функцию Reverse, пройдемся по строке по рунам, а не по байтам.

Напишите код

В текстовом редакторе замените существующую функцию Reverse() следующей.

func Reverse(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

Ключевое отличие состоит в том, что Reverse теперь перебирает каждую руну в строке, а не каждый байт.

Запустите код

Запустите тест, используя go test

$ go test
PASS
ok      example/fuzz  0.016s

Теперь тест проходит!

Еще раз проверьте его с помощью go test -fuzz, чтобы увидеть, есть ли какие-либо новые ошибки.

$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/37 completed
fuzz: minimizing 506-byte failing input file...
fuzz: elapsed: 0s, gathering baseline coverage: 5/37 completed
--- FAIL: FuzzReverse (0.02s)
    --- FAIL: FuzzReverse (0.00s)
        reverse_test.go:33: Before: "\x91", after: "�"

    Failing input written to testdata/fuzz/FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c
    To re-run:
    go test -run=FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c
FAIL
exit status 1
FAIL    example/fuzz  0.032s

Мы можем видеть, что строка отличается от оригинала после того, как дважды была перевернута. На этот раз сам ввод является недопустимым юникодом. Как это возможно, если мы фаззим со строками?

Давайте снова отладим.

Исправить ошибку двойного реверса

В этом разделе вы отладите ошибку двойного реверса и исправите ее.

Диагностируйте ошибку

Как и раньше, есть несколько способов отладки этого сбоя. В этом случае использование отладчика было бы отличным подходом.

В этом руководстве мы будем регистрировать полезную информацию об отладке в функции Reverse.

Посмотрите внимательно на перевернутую строку, чтобы найти ошибку. В Go строка представляет собой срез байтов, доступный только для чтения, и может содержать байты, которые не являются допустимыми UTF-8. Исходная строка представляет собой срез байтов с одним байтом, '\x91'. Когда входная строка имеет значение []rune, Go кодирует байтовый срез в UTF-8 и заменяет байт символом UTF-8 �. Когда мы сравниваем замещающий символ UTF-8 с входным байтовым срезом, они явно не равны.

Напишите код

В текстовом редакторе замените функцию Reverse на следующую.

func Reverse(s string) string {
    fmt.Printf("input: %q\n", s)
    r := []rune(s)
    fmt.Printf("runes: %q\n", r)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

Это поможет нам понять, что происходит не так при преобразовании строки в срез рун.

Запустите код

На этот раз мы хотим запустить только неудачный тест, чтобы проверить логи. Для этого мы будем использовать go test -run.

$ go test -run=FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0
input: "\x91"
runes: ['�']
input: "�"
runes: ['�']
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s)
        reverse_test.go:16: Number of runes: orig=1, rev=1, doubleRev=1
        reverse_test.go:18: Before: "\x91", after: "�"
FAIL
exit status 1
FAIL    example/fuzz    0.145s

Чтобы запустить конкретную запись корпуса в FuzzXxx/testdata, вы можете указать {FuzzTestName}/{filename} для -run. Это может быть полезно при отладке.

Зная, что ввод является недопустимым юникодом, давайте исправим ошибку в нашей функции Reverse.

Исправьте ошибку

Чтобы решить эту проблему, давайте вернем ошибку, если ввод в Reverse недействителен в кодировке UTF-8.

Напишите код

В текстовом редакторе замените существующую функцию Reverse на следующую.

func Reverse(s string) (string, error) {
    if !utf8.ValidString(s) {
        return s, errors.New("input is not valid UTF-8")
    }
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r), nil
}

Это изменение вернет ошибку, если входная строка содержит символы, недопустимые в кодировке UTF-8.

Поскольку функция Reverse теперь возвращает ошибку, измените main функцию, чтобы отбросить дополнительное значение ошибки. Замените существующую main функцию следующей.

func main() {
    input := "The quick brown fox jumped over the lazy dog"
    rev, revErr := Reverse(input)
    doubleRev, doubleRevErr := Reverse(rev)
    fmt.Printf("original: %q\n", input)
    fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
    fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)
}

Эти вызовы Reverse должны возвращать нулевую ошибку, поскольку входная строка является допустимой UTF-8.

Вам нужно будет импортировать пакеты errors и unicode/utf8. Оператор импорта в main.go должен выглядеть следующим образом.

import (
    "errors"
    "fmt"
    "unicode/utf8"
)

Измените файл reverse_test.go, чтобы проверить наличие ошибок и пропустить тест, если ошибки генерируются при возврате.

func FuzzReverse(f *testing.F) {
    testcases := []string {"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc)  //  Используйте f.Add для предоставления начального корпуса (seed corpus)
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev, err1 := Reverse(orig)
        if err1 != nil {
            return
        }
        doubleRev, err2 := Reverse(rev)
        if err2 != nil {
             return
        }
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}

Вместо возврата вы также можете вызвать t.Skip(), чтобы остановить выполнение этого fuzz ввода.

Запустите код

Запустите тест, используя go test

$ go test
PASS
ok      example/fuzz  0.019s

Фаззите с помощью go test -fuzz=Fuzz, затем через несколько секунд прекратите фаззинг, нажав Ctrl-C.

$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/38 completed
fuzz: elapsed: 0s, gathering baseline coverage: 38/38 completed, now fuzzing with 4 workers
fuzz: elapsed: 3s, execs: 86342 (28778/sec), new interesting: 2 (total: 35)
fuzz: elapsed: 6s, execs: 193490 (35714/sec), new interesting: 4 (total: 37)
fuzz: elapsed: 9s, execs: 304390 (36961/sec), new interesting: 4 (total: 37)
...
fuzz: elapsed: 3m45s, execs: 7246222 (32357/sec), new interesting: 8 (total: 41)
^Cfuzz: elapsed: 3m48s, execs: 7335316 (31648/sec), new interesting: 8 (total: 41)
PASS
ok      example/fuzz  228.000s

fuzz тест будет выполняться до тех пор, пока не обнаружит ошибочный ввод, если только вы не передадите флаг -fuzztime. По умолчанию выполняется вечно, если не происходит сбоев, и процесс можно прервать с помощью Ctrl-C.

Проведите фаззинг с помощью go test -fuzz=Fuzz -fuzztime 30s, который будет фаззить в течение 30 секунд перед выходом, если сбоев не обнаружено.

$ go test -fuzz=Fuzz -fuzztime 30s
fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 4 workers
fuzz: elapsed: 3s, execs: 80290 (26763/sec), new interesting: 12 (total: 12)
fuzz: elapsed: 6s, execs: 210803 (43501/sec), new interesting: 14 (total: 14)
fuzz: elapsed: 9s, execs: 292882 (27360/sec), new interesting: 14 (total: 14)
fuzz: elapsed: 12s, execs: 371872 (26329/sec), new interesting: 14 (total: 14)
fuzz: elapsed: 15s, execs: 517169 (48433/sec), new interesting: 15 (total: 15)
fuzz: elapsed: 18s, execs: 663276 (48699/sec), new interesting: 15 (total: 15)
fuzz: elapsed: 21s, execs: 771698 (36143/sec), new interesting: 15 (total: 15)
fuzz: elapsed: 24s, execs: 924768 (50990/sec), new interesting: 16 (total: 16)
fuzz: elapsed: 27s, execs: 1082025 (52427/sec), new interesting: 17 (total: 17)
fuzz: elapsed: 30s, execs: 1172817 (30281/sec), new interesting: 17 (total: 17)
fuzz: elapsed: 31s, execs: 1172817 (0/sec), new interesting: 17 (total: 17)
PASS
ok      example/fuzz  31.025s

Фаззинг пройден!

Завершенный код — main.go —

package main

import (
    "errors"
    "fmt"
    "unicode/utf8"
)

func main() {
    input := "The quick brown fox jumped over the lazy dog"
    rev, revErr := Reverse(input)
    doubleRev, doubleRevErr := Reverse(rev)
    fmt.Printf("original: %q\n", input)
    fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
    fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)
}

func Reverse(s string) (string, error) {
    if !utf8.ValidString(s) {
        return s, errors.New("input is not valid UTF-8")
    }
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r), nil
}

— reverse_test.go —

package main

import (
    "testing"
    "unicode/utf8"
)

func FuzzReverse(f *testing.F) {
    testcases := []string{"Hello, world", " ", "!12345"}
    for _, tc := range testcases {
        f.Add(tc) // Use f.Add to provide a seed corpus
    }
    f.Fuzz(func(t *testing.T, orig string) {
        rev, err1 := Reverse(orig)
        if err1 != nil {
            return
        }
        doubleRev, err2 := Reverse(rev)
        if err2 != nil {
            return
        }
        if orig != doubleRev {
            t.Errorf("Before: %q, after: %q", orig, doubleRev)
        }
        if utf8.ValidString(orig) && !utf8.ValidString(rev) {
            t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
        }
    })
}


Читайте также:


Комментариев нет:

Отправить комментарий