Skip to content

checkptr crash in decodeKeyByBitmapUint8 #577

Description

@chippyash

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions