Bugs Fuzzing Finds in Real Go Code and How to Fix Them
Four bug classes fuzzing finds in Go code, each with a function, a fuzz target, the saved input and the fix: bad lengths, escaping order, slice bounds, UTF-8.

Bug finding tools are easiest to trust when you have seen what they find. Fuzzing has a reputation for turning up exotic crashes in code written by experts, and it does. The more useful fact for most teams is how ordinary the bugs are. They are short functions written in a hurry, with a missing check, and they sit in the code that handles input.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This list takes four classes, each with a function small enough to read at once, the fuzz target, the input the fuzzer saved, and the repair. The functions are written to contain the bug, not copied from a real code base, and the fuzzer output is from a real run on Go 1.25.6. The method behind it is in the fuzzing guide, and the advice on writing the properties is in the article on parsers.
The functions
package main
import (
"strconv"
"strings"
)
// Repeat builds s repeated n times into one buffer.
func Repeat(s string, n int) string {
buf := make([]byte, len(s)*n)
for i := 0; i < n; i++ {
copy(buf[i*len(s):], s)
}
return string(buf)
}
// Escape and Unescape quote backslashes and commas for a comma separated field.
func Escape(s string) string {
s = strings.ReplaceAll(s, `,`, `\,`)
return strings.ReplaceAll(s, `\`, `\\`)
}
func Unescape(s string) string {
s = strings.ReplaceAll(s, `\,`, `,`)
return strings.ReplaceAll(s, `\\`, `\`)
}
// Slice returns the bytes selected by a header such as "5-9" out of data.
func Slice(data []byte, spec string) []byte {
lo, hi, _ := strings.Cut(spec, "-")
a, _ := strconv.Atoi(lo)
b, _ := strconv.Atoi(hi)
if b >= len(data) {
b = len(data) - 1
}
return data[a : b+1]
}
func main() {}package main
import "testing"
func FuzzRepeat(f *testing.F) {
f.Add("ab", 3)
f.Fuzz(func(t *testing.T, s string, n int) {
_ = Repeat(s, n)
})
}
func FuzzEscape(f *testing.F) {
f.Add("a,b")
f.Fuzz(func(t *testing.T, s string) {
if got := Unescape(Escape(s)); got != s {
t.Errorf("Unescape(Escape(%q)) = %q", s, got)
}
})
}
func FuzzSlice(f *testing.F) {
f.Add("5-9")
data := []byte("0123456789abcdef")
f.Fuzz(func(t *testing.T, spec string) {
_ = Slice(data, spec)
})
}rm -rf testdata
F='s/[0-9a-f]{16}/<hash>/g; s/ \([0-9.]+s\)//; s/[0-9.]+s$/<time>/'
for t in FuzzRepeat FuzzEscape FuzzSlice; do
echo "# $t"
go test -run='^$' -fuzz="^$t\$" -fuzztime=10s . 2>&1 | grep -E 'panic:|Unescape|slice bounds|makeslice|FAIL: Fuzz' | head -3 | sed -E "$F"
cat testdata/fuzz/$t/* 2>/dev/null | tail -n +2
done# FuzzRepeat
--- FAIL: FuzzRepeat
--- FAIL: FuzzRepeat
testing.go:1825: panic: runtime error: makeslice: len out of range
string("0")
int(-57)
# FuzzEscape
--- FAIL: FuzzEscape
--- FAIL: FuzzEscape
bugs_test.go:16: Unescape(Escape("a,b")) = "a\\,b"
string(",,")
# FuzzSlice
--- FAIL: FuzzSlice
--- FAIL: FuzzSlice
testing.go:1825: panic: runtime error: slice bounds out of range [2:1]
string("2")Each target is a few lines. FuzzRepeat and FuzzSlice only call the function: a panic is the failure. FuzzEscape states the one property an escaper has, that unescaping an escaped value gives the value back. The saved input for each is printed after the failure, one string(...) or int(...) line per argument.
1. Arithmetic on a length
Repeat allocates len(s)*n bytes and copies into the buffer. The fuzzer found that n can be negative, and make with a negative length panics with makeslice: len out of range. The saved input is a one character string and a negative integer.
A negative number is the easy case. The same line also overflows: len(s)*n wraps around for a large n and can come out small or negative, and then the loop writes past what was allocated, or the program tries to allocate gigabytes. Any code that multiplies a length by a value it did not control has this bug.
Fix. Validate before arithmetic: reject n < 0, and check n > maxLen/len(s) before multiplying, as the standard library's strings.Repeat does, which panics with a clear message on a negative count and on a result that overflows. Better, use strings.Repeat. The general rule is to check each operand before combining them, since a check after the multiplication is checking the wrong number.
2. Escape and unescape in the wrong order
Escape puts a backslash before each comma and then doubles every backslash; Unescape undoes the pair in an order that is not the reverse. Each function looks right on its own, and the pair looks right on the examples an author writes. The fuzzer did not need to mutate anything: the seed a,b failed on the first execution, with Unescape(Escape("a,b")) returning a followed by a backslash, a comma and b. The saved file is a different and shorter value, a pair of commas.
The mistake is order. Escaping the comma first and then doubling backslashes also doubles the backslash that was just added, so one comma becomes a doubled backslash and a comma. The decoder, expecting a single backslash before the comma, meets the double first and gets it wrong. Escaping must double existing backslashes before it adds any new one, and unescaping must run its steps in the reverse order. Hand-written examples miss this because the author writes one comma case and one backslash case, and never both in the same string.
This is also a bug with a security flavour. The same shape appears in code that builds shell commands, SQL fragments, CSV rows and log lines, where a wrongly escaped value is the difference between data and syntax.
Fix. Do the work in a single pass, character by character, instead of in sequential replacements. A loop that copies each rune and writes the escape before the special ones has no ordering to get wrong. For the common formats, use the library: net/url, encoding/csv, strconv.Quote. The round trip property then guards every future edit. This is the category where fuzzing earns its keep, because almost every encoder has the property and almost no encoder's tests exercise it enough.
3. A slice bound from parsed input
Slice takes a range header such as 5-9 and returns that part of the data. It clamps the upper bound to the data length and trusts the lower one. The fuzzer's saved value is the string "2": no dash, so hi is empty, Atoi returns 0 for it, and the call becomes data[2:1], which panics with slice bounds out of range [2:1].
The same function panics on "20-30" with a lower bound beyond the data, on "9-5" where the range is reversed, and on "-3" where the number parses as negative. Four inputs, one cause. The code converts text to numbers and uses them as indices without asking whether they describe a valid range. HTTP range handling, pagination offsets and file formats with offsets and lengths are the usual homes of this bug.
Fix. Check the relationship between the numbers, not just their size: 0 <= a <= b < len(data), and return an error when it does not hold. Check the error from Atoi too, since the lab discarded it, which is how "2" was accepted at all. A panic on bad input from a client is an availability bug that anyone can trigger on purpose.
4. Bytes where characters were meant
The example from the fuzzing guide belongs on this list. Reversing a string by bytes works for ASCII and breaks the encoding of any other character, so the output is no longer valid UTF-8. The fuzzer found it with a four byte character. The same mistake appears when truncating to a length in bytes, so the last character is cut in half, and when indexing a string with s[i] and treating the result as a letter.
Fix. Decide whether the function works on bytes or on characters and say so in the name. Use []rune, utf8.DecodeRuneInString or for range for characters. For truncation, back up to a rune boundary with utf8.RuneStart. The standard unicode/utf8 package documents each helper.
What the four have in common
None of the bugs is clever. Each comes from an assumption about the input that the function's author held without noticing it: lengths are non-negative, characters do not collide with the escape character, numbers describe a valid range, text is ASCII. A fuzzer is a machine for violating assumptions, and it costs nothing to ask it.
Three patterns show up in the targets too. The cheapest property is "do not panic", and three of the four findings needed nothing more. The next cheapest is the round trip. Anything with an inverse should be tested against it. And the saved inputs are tiny. A fuzzer works to shrink a failure, so what you review in the pull request is a line or two, which makes it easy to understand and to keep as a regression test.
What fuzzing does not find
It will not find a logic error that produces a plausible wrong answer unless a property notices. A function that reverses a string and also drops the last character passes the round trip if the same mistake is in both directions. It will not find slow paths by itself either: a quadratic loop only shows up as a hang if the target has a time limit. It sees only the inputs your target can express, so a bug that needs a particular sequence of calls needs a target that makes those calls. For those, the model based approach in property based testing fits better.
A good habit is to add one fuzz target whenever you add a function that takes text or bytes from outside the program, and to write it before the function has a second caller. The target is five lines, it runs as a table test in every build, and it is the only test in the file that you did not have to think of the cases for. Treat the fuzzer as the cheapest reviewer you have. It reads nothing, knows nothing about intent, and tries ten thousand inputs while you pour a coffee. The Go documentation on fuzzing and the strings.Repeat source show the library-grade versions of these fixes.


