schemagen: generated integer parser overflows int64 (UB) when a saturated negative exponent is combined with the fraction #73

Open
opened 2026-10-31 13:05:12 +00:00 by weaselbot · 0 comments
Member

Summary

The integer parser that schemagen embeds in every generated header (parseJsonInt64) uses a saturating sentinel (INT64_MAX) to clamp a too-large exponent, then negates it and immediately does unchecked int64 arithmetic with it:

# contrib/schemagen/weaseljson_schemagen.py:1062-1068
      while (p < e && *p >= '0' && *p <= '9') {{
        int digit = *p - '0';
        if (exp <= (INT64_MAX - digit) / 10) exp = exp * 10 + digit;
        else exp = INT64_MAX;          # saturation sentinel
        ++p;
      }}
      if (expNeg) exp = -exp;          # => exp == -INT64_MAX

# contrib/schemagen/weaseljson_schemagen.py:1082
    int64_t finalExp = exp - fracDigits + trim;   # UB when exp == -INT64_MAX and fracDigits >= 2

-INT64_MAX - fracDigits underflows as soon as the fraction has two or more digits, so the generated code has signed-integer overflow (undefined behaviour) for a perfectly ordinary JSON number. UBSan aborts the parse of that document; with -fsanitize-recover it reports and continues, and the second sub-expression overflows too.

Nothing rejects the saturated sentinel before it is combined with the fraction count, and the overflow happens before the "all digits are zero" early-return, so it is not avoided by zero mantissas either.

Reproduction

mkdir -p /tmp/repro && cd /tmp/repro
REPO=/path/to/weaseljson

printf '%s' '{"type":"object","additionalProperties":false,"properties":{"age":{"type":"integer"}},"required":["age"]}' > schema.json
python3 "$REPO/contrib/schemagen/weaseljson_schemagen.py" schema.json -o gen.h --namespace fg

cat > test.cpp <<'EOF'
#include "gen.h"
#include <cstdio>
#include <string>
int main() {
  std::string doc = R"({"age":1.12e-9223372036854775808})";
  fg::RootBuilder b;
  WeaselJsonStatus st = b.feed(doc.data(), (int)doc.size());
  st = b.finish();
  std::printf("%d\n", (int)st);
}
EOF

clang++ -std=c++20 -O2 -fsanitize=undefined -fno-sanitize-recover=undefined \
        -I "$REPO/include" -I . test.cpp "$REPO/src/lib.cpp" -o test
./test

Observed (clang 22.1.8, exit status 1):

gen.h:137:28: runtime error: signed integer overflow: -9223372036854775807 - 2 cannot be represented in type 'int64_t' (aka 'long')
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior gen.h:137:28

Identical failure with g++ 16.1.1 (... in type 'long int'). The same happens for a root-level {"type": "integer"} schema with the document 1.12e-99999999999999999999, and for any integer property or array item, because parseJsonInt64 is emitted once and used by every Cat::Int slot.

Further observations from the same repro:

  • {"age":1.1e-9223372036854775808} (one fraction digit) does not overflow — -INT64_MAX - 1 == INT64_MIN is still representable. Two or more fraction digits are enough.
  • With recoverable UBSan, {"age":0.00e-9223372036854775808} reports a second overflow on the same line (gen.h:137:41: signed integer overflow: 9223372036854775807 + 3), i.e. both sub-expressions are UB.
  • {"age":0.12e-9223372036854775808} also overflows (the overflow precedes the all-zero short-circuit at line 1085-1086 of the generator).

Expected vs actual

  • Expected: the number is not representable as an int64_t integer, so the slot is rejected; the generated code must not execute undefined behaviour on the way there.
  • Actual (UBSan build): the generated parser trips signed integer overflow and, with the -fno-sanitize-recover flag that fuzzing/CI harnesses normally use, aborts while parsing a 30-byte valid JSON document.
  • Actual (plain build): it happens to return WeaselJson_REJECT, but only because the wrapped value is astronomically large and is then caught by the incidental if (finalExp > 19) return false; check. The intended check never sees a real exponent: finalExp is not a meaningful number once exp has saturated, so the rejection is an accident of two's-complement wrap-around rather than a decision the code makes.

Impact

Generated parsers are meant to validate untrusted input. Any document whose exponent exceeds the int64 range while the fraction has two or more digits (e.g. 1.12e-9223372036854775808, 0.12e-99999999999999999999) puts the parser into undefined behaviour:

  • sanitizer-instrumented builds (the normal way to fuzz/CI-test a parser) fail hard on such input;
  • the compiler is entitled to assume the subtraction never overflows, so the surrounding finalExp < 0 / finalExp > 19 range checks on a wrapped value are not something the code can rely on.

Suggested fix

Keep the saturated sentinel out of the arithmetic. For example, reject/clamp the exponent before it is combined with fracDigits:

if (expNeg) exp = -exp;
...
// A negative exponent beyond the int64 range can never yield a nonzero integer.
if (exp < -20 - static_cast<int64_t>(fracDigits)) return false;
int64_t finalExp = exp - fracDigits + trim;

or use the overflow intrinsics:

int64_t finalExp;
if (__builtin_sub_overflow(exp, static_cast<int64_t>(fracDigits), &finalExp)) return false;
if (__builtin_add_overflow(finalExp, trim, &finalExp)) return false;

A regression test in contrib/schemagen/test_schemagen.py (in the style of SchemagenIntegerBoundaryTest) that feeds {"age":1.12e-9223372036854775808} and expects WeaselJson_REJECT would cover it, and the generated code should be built with -fsanitize=undefined -fno-sanitize-recover=undefined so the UB shows up.

## Summary The integer parser that schemagen embeds in every generated header (`parseJsonInt64`) uses a saturating sentinel (`INT64_MAX`) to clamp a too-large exponent, then negates it and immediately does unchecked int64 arithmetic with it: ```python # contrib/schemagen/weaseljson_schemagen.py:1062-1068 while (p < e && *p >= '0' && *p <= '9') {{ int digit = *p - '0'; if (exp <= (INT64_MAX - digit) / 10) exp = exp * 10 + digit; else exp = INT64_MAX; # saturation sentinel ++p; }} if (expNeg) exp = -exp; # => exp == -INT64_MAX # contrib/schemagen/weaseljson_schemagen.py:1082 int64_t finalExp = exp - fracDigits + trim; # UB when exp == -INT64_MAX and fracDigits >= 2 ``` `-INT64_MAX - fracDigits` underflows as soon as the fraction has two or more digits, so the generated code has signed-integer overflow (undefined behaviour) for a perfectly ordinary JSON number. UBSan aborts the parse of that document; with `-fsanitize-recover` it reports and continues, and the second sub-expression overflows too. Nothing rejects the saturated sentinel before it is combined with the fraction count, and the overflow happens *before* the "all digits are zero" early-return, so it is not avoided by zero mantissas either. ## Reproduction ```sh mkdir -p /tmp/repro && cd /tmp/repro REPO=/path/to/weaseljson printf '%s' '{"type":"object","additionalProperties":false,"properties":{"age":{"type":"integer"}},"required":["age"]}' > schema.json python3 "$REPO/contrib/schemagen/weaseljson_schemagen.py" schema.json -o gen.h --namespace fg cat > test.cpp <<'EOF' #include "gen.h" #include <cstdio> #include <string> int main() { std::string doc = R"({"age":1.12e-9223372036854775808})"; fg::RootBuilder b; WeaselJsonStatus st = b.feed(doc.data(), (int)doc.size()); st = b.finish(); std::printf("%d\n", (int)st); } EOF clang++ -std=c++20 -O2 -fsanitize=undefined -fno-sanitize-recover=undefined \ -I "$REPO/include" -I . test.cpp "$REPO/src/lib.cpp" -o test ./test ``` Observed (clang 22.1.8, exit status 1): ``` gen.h:137:28: runtime error: signed integer overflow: -9223372036854775807 - 2 cannot be represented in type 'int64_t' (aka 'long') SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior gen.h:137:28 ``` Identical failure with g++ 16.1.1 (`... in type 'long int'`). The same happens for a root-level `{"type": "integer"}` schema with the document `1.12e-99999999999999999999`, and for any integer property or array item, because `parseJsonInt64` is emitted once and used by every `Cat::Int` slot. Further observations from the same repro: * `{"age":1.1e-9223372036854775808}` (one fraction digit) does **not** overflow — `-INT64_MAX - 1 == INT64_MIN` is still representable. Two or more fraction digits are enough. * With recoverable UBSan, `{"age":0.00e-9223372036854775808}` reports a second overflow on the same line (`gen.h:137:41: signed integer overflow: 9223372036854775807 + 3`), i.e. both sub-expressions are UB. * `{"age":0.12e-9223372036854775808}` also overflows (the overflow precedes the all-zero short-circuit at line 1085-1086 of the generator). ## Expected vs actual * Expected: the number is not representable as an `int64_t` integer, so the slot is rejected; the generated code must not execute undefined behaviour on the way there. * Actual (UBSan build): the generated parser trips `signed integer overflow` and, with the `-fno-sanitize-recover` flag that fuzzing/CI harnesses normally use, aborts while parsing a 30-byte valid JSON document. * Actual (plain build): it happens to return `WeaselJson_REJECT`, but only because the wrapped value is astronomically large and is then caught by the incidental `if (finalExp > 19) return false;` check. The intended check never sees a real exponent: `finalExp` is not a meaningful number once `exp` has saturated, so the rejection is an accident of two's-complement wrap-around rather than a decision the code makes. ## Impact Generated parsers are meant to validate untrusted input. Any document whose exponent exceeds the `int64` range while the fraction has two or more digits (e.g. `1.12e-9223372036854775808`, `0.12e-99999999999999999999`) puts the parser into undefined behaviour: * sanitizer-instrumented builds (the normal way to fuzz/CI-test a parser) fail hard on such input; * the compiler is entitled to assume the subtraction never overflows, so the surrounding `finalExp < 0` / `finalExp > 19` range checks on a wrapped value are not something the code can rely on. ## Suggested fix Keep the saturated sentinel out of the arithmetic. For example, reject/clamp the exponent before it is combined with `fracDigits`: ```cpp if (expNeg) exp = -exp; ... // A negative exponent beyond the int64 range can never yield a nonzero integer. if (exp < -20 - static_cast<int64_t>(fracDigits)) return false; int64_t finalExp = exp - fracDigits + trim; ``` or use the overflow intrinsics: ```cpp int64_t finalExp; if (__builtin_sub_overflow(exp, static_cast<int64_t>(fracDigits), &finalExp)) return false; if (__builtin_add_overflow(finalExp, trim, &finalExp)) return false; ``` A regression test in `contrib/schemagen/test_schemagen.py` (in the style of `SchemagenIntegerBoundaryTest`) that feeds `{"age":1.12e-9223372036854775808}` and expects `WeaselJson_REJECT` would cover it, and the generated code should be built with `-fsanitize=undefined -fno-sanitize-recover=undefined` so the UB shows up.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: weaselab/weaseljson#73