Package: github.com/goccy/go-json v0.10.6
Go version: go1.26.0 linux/amd64
Severity: Crash (fatal, not recoverable)
Trigger: go test -race with a specific seed corpus entry
Summary
go-json's decodeKeyByBitmapUint8 function reads bytes from a buffer using raw unsafe.Pointer arithmetic. With a 6-byte input {"\x00d\, the computed pointer lands one byte past the end of the input allocation. Go's checkptr instrumentation (implicitly enabled by -race) detects this and crashes the process with a fatal error.
Reproducer
go test fuzz v1
string("{\"\\0d\\")
go test -tags=unit -race -run <file to test>
Expected: test passes (the input is invalid JSON — handler should return 400).
Actual: fatal crash.
Crash output
fatal error: checkptr: pointer arithmetic result points to invalid allocation
goroutine 77 [running]:
runtime.checkptrArithmetic(0xc000318339?, {0xc00030a770, 0x1, 0x6?})
runtime/checkptr.go:69 +0x9c
github.com/goccy/go-json/internal/decoder.char(...)
github.com/goccy/go-json@v0.10.6/internal/decoder/context.go:45
github.com/goccy/go-json/internal/decoder.decodeKeyByBitmapUint8(0xc00025eaa0, {0xc000318339, 0x7, 0x7}, 0x1)
github.com/goccy/go-json@v0.10.6/internal/decoder/struct.go:245 +0x513
github.com/goccy/go-json/internal/decoder.(*structDecoder).Decode(...)
github.com/goccy/go-json@v0.10.6/internal/decoder/struct.go:782 +0x85d
github.com/goccy/go-json.unmarshal(...)
github.com/goccy/go-json@v0.10.6/decode.go:47 +0x2cd
Root cause
char() — unsafe pointer arithmetic
internal/decoder/context.go:45:
func char(ptr unsafe.Pointer, offset int64) byte {
return *(*byte)(unsafe.Pointer(uintptr(ptr) + uintptr(offset)))
}
This casts a []byte's backing data pointer (via an internal sliceHeader
cast) into a uintptr, adds offset, and dereferences the result. This is a
known unsafe pattern: uintptr arithmetic loses the GC root, and checkptr
validates the resulting pointer against known allocations at runtime.
decodeKeyByBitmapUint8 — reads at offset == len(buf)
internal/decoder/struct.go:227–245:
b := (*sliceHeader)(unsafe.Pointer(&buf)).data // raw data pointer
for {
switch char(b, cursor) { // cursor walks forward through the buffer
...
for {
c := char(b, cursor) // line 245 — crash site
For the input {"\x00d\ (6 bytes), the outer loop enters the " branch at
cursor=1, then the inner key-scanning loop advances cursor until it reaches
index 6, which is one past the end of the 6-byte allocation. char(b, 6) adds
6 to the data pointer, producing an address that falls outside the slice
backing array. checkptr catches this.
The read is logically safe on real hardware (the byte returned is a nul that
terminates scanning), but it is undefined behaviour under the Go memory model
and illegal under checkptr.
Why -race triggers it
go test -race enables both the race detector and checkptr (Go's unsafe
pointer validation). Without -race the identical binary path executes without
complaint because no runtime check is installed. The bug exists in both cases
but is only observable under checkptr.
Why only this corpus entry triggers it
Most inputs either (a) are long enough that the off-by-one read still falls
within the backing allocation (Go's allocator rounds up, providing a small
implicit guard), or (b) take a different code path through the decoder. The
6-byte string {"\x00d\ is short enough that the allocation is exactly 6
bytes, leaving no padding, and the key-scanning loop walks exactly to
cursor == len(buf).
Workaround (applied in author's affected code)
Split the make test target so that Fuzz* seed-corpus replays run without
-race, while Test* unit tests run with -race:
test:
go test -tags=unit -race -run '^Test' -coverprofile=coverage.out \
-covermode=atomic $(shell go list -tags=unit \
-f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...)
go test -tags=unit -run '^Fuzz' -count=1 ./...
go tool cover -func=coverage.out
This preserves race detection for all table-driven and mock-based unit tests
while avoiding checkptr for fuzz seed replays.
Affected versions
go-json v0.10.6 — latest available at time of writing; no newer release exists.
- Likely present in all prior versions that use
char() + bitmap key decoding.
Suggest Fix
The suggested fix is for go-json to bounds-check cursor before calling
char(), or to replace the char() helper with a standard slice index
(buf[cursor]) which Go bounds-checks natively and which satisfies checkptr.
Caveat
The bug report was generated by Claude Code after it identified the issue when creating fuzz tests for my code base
Package:
github.com/goccy/go-json v0.10.6Go version:
go1.26.0 linux/amd64Severity: Crash (fatal, not recoverable)
Trigger:
go test -racewith a specific seed corpus entrySummary
go-json'sdecodeKeyByBitmapUint8function reads bytes from a buffer using rawunsafe.Pointerarithmetic. With a 6-byte input{"\x00d\, the computed pointer lands one byte past the end of the input allocation. Go'scheckptrinstrumentation (implicitly enabled by-race) detects this and crashes the process with a fatal error.Reproducer
Expected: test passes (the input is invalid JSON — handler should return 400).
Actual: fatal crash.
Crash output
Root cause
char()— unsafe pointer arithmeticinternal/decoder/context.go:45:This casts a
[]byte's backingdatapointer (via an internalsliceHeadercast) into a
uintptr, addsoffset, and dereferences the result. This is aknown unsafe pattern:
uintptrarithmetic loses the GC root, andcheckptrvalidates the resulting pointer against known allocations at runtime.
decodeKeyByBitmapUint8— reads at offset == len(buf)internal/decoder/struct.go:227–245:For the input
{"\x00d\(6 bytes), the outer loop enters the"branch atcursor=1, then the inner key-scanning loop advancescursoruntil it reachesindex 6, which is one past the end of the 6-byte allocation.
char(b, 6)adds6 to the data pointer, producing an address that falls outside the slice
backing array.
checkptrcatches this.The read is logically safe on real hardware (the byte returned is a nul that
terminates scanning), but it is undefined behaviour under the Go memory model
and illegal under
checkptr.Why
-racetriggers itgo test -raceenables both the race detector andcheckptr(Go's unsafepointer validation). Without
-racethe identical binary path executes withoutcomplaint because no runtime check is installed. The bug exists in both cases
but is only observable under
checkptr.Why only this corpus entry triggers it
Most inputs either (a) are long enough that the off-by-one read still falls
within the backing allocation (Go's allocator rounds up, providing a small
implicit guard), or (b) take a different code path through the decoder. The
6-byte string
{"\x00d\is short enough that the allocation is exactly 6bytes, leaving no padding, and the key-scanning loop walks exactly to
cursor == len(buf).Workaround (applied in author's affected code)
Split the
make testtarget so thatFuzz*seed-corpus replays run without-race, whileTest*unit tests run with-race:This preserves race detection for all table-driven and mock-based unit tests
while avoiding
checkptrfor fuzz seed replays.Affected versions
go-json v0.10.6— latest available at time of writing; no newer release exists.char()+ bitmap key decoding.Suggest Fix
The suggested fix is for
go-jsonto bounds-checkcursorbefore callingchar(), or to replace thechar()helper with a standard slice index(
buf[cursor]) which Go bounds-checks natively and which satisfiescheckptr.Caveat
The bug report was generated by Claude Code after it identified the issue when creating fuzz tests for my code base