Skip to content

Validate FlatZinc constraint arity before indexing arguments (heap OOB read)#5228

Open
evilgensec wants to merge 3 commits into
google:stablefrom
evilgensec:fix-flatzinc-arity-oob
Open

Validate FlatZinc constraint arity before indexing arguments (heap OOB read)#5228
evilgensec wants to merge 3 commits into
google:stablefrom
evilgensec:fix-flatzinc-arity-oob

Conversation

@evilgensec

Copy link
Copy Markdown

Problem

The FlatZinc front end accepts a constraint <name>(<args>) declaration with any number
of arguments; the parser performs no arity check (parser.yy stores the argument vector
verbatim). Both consumers that dispatch on the constraint name then read fixed positions of
constraint.arguments:

  • the CP-SAT extractor CpModelProtoWithMapping::FillConstraint (cp_model_fz_solver.cc),
    invoked for every constraint on every solve, e.g. BoolNeConstraint reads arguments[0]
    and arguments[1], IntLinEqConstraint reads arguments[2].values[0];
  • the solution checker CheckSolution (checker.cc), e.g. CheckBoolNot reads
    arguments[1].

Neither validates arguments.size() first. A hand-crafted .fzn that declares a known
constraint with too few arguments drives arguments[k] past the end of the heap-allocated
std::vector<fz::Argument>, an out-of-bounds read. The attacker chooses the constraint
type and therefore which fixed offset is read past the end; deeper handlers dereference the
out-of-bounds Argument's pointer/list members. .fzn is untrusted input for
fzn-ortools (constraint-solving services, CI, MiniZinc backends).

Reproduced with AddressSanitizer through the real ParseFlatzincString + CheckSolution:

var bool: x :: output_var;
constraint bool_not(x);   // bool_not takes 2 arguments; one given
solve satisfy;
==..==ERROR: AddressSanitizer: heap-buffer-overflow ... READ of size 4
  #0 ...Eval(...) checker.cc:41
  #1 ...CheckBoolNot(...) checker.cc:374
  #2 ...CheckSolution(...) checker.cc:1921
0x.. is located 0 bytes after a 104-byte region allocated in orfz_parse (parser.yy)

Fix

Add NumRequiredArguments(absl::string_view type) (in model.{h,cc}): a table of the
minimum number of arguments each constraint type needs, derived from the fixed argument
positions read by the checker and extractor handlers (the max literal arguments[k] index

  • 1; FlatZinc constraints have a fixed per-type arity, so requiring it never rejects a
    well-formed model). Unknown types return 0 (no restriction). Both CheckSolution and
    FillConstraint reject a constraint with fewer arguments before dispatching.

With this change the crafted model above no longer reads out of bounds: the checker reports
the constraint as unsatisfied (CheckSolution returns false, ASAN clean), and the
extractor errors out instead of indexing past the vector.

The arity table is generated from the current handler set; the FlatZinc regression suite in
CI exercises the well-formed paths to confirm no valid model is rejected.

The FlatZinc front end accepts a constraint declaration with any number of
arguments; the parser performs no arity check. Both the solution checker
(CheckSolution) and the CP-SAT extractor (FillConstraint) then dispatch on the
constraint type and read fixed positions of constraint.arguments, assuming a
well-formed model always supplies the right count. A hand-crafted .fzn that
declares a known constraint with too few arguments causes an out-of-bounds read
past the heap-allocated std::vector<fz::Argument> (the attacker chooses the type
and therefore which fixed offset is read past the end, and deeper handlers
dereference the out-of-bounds Argument's pointer/list members).

Add NumRequiredArguments(type), a table of the minimum argument count each
constraint type needs (derived from the fixed positions read by the checker and
extractor handlers), and reject constraints with fewer arguments before
dispatching in both consumers.
@lperron

lperron commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

This is just a tad over-engineered.
Just check the arity without lookup at the start of each check method.

This being said, creating the fzn file should be done by the minizinc compiler, so they should not generate invalid fzn files

Replace the central NumRequiredArguments table and the dispatch-level
checks with a direct argument-count guard at the start of each checker
method, as suggested in review. Each Check* function now verifies it has
enough arguments before indexing, so a malformed FlatZinc constraint with
too few arguments is treated as unsatisfied instead of reading out of
bounds on its argument vector.

Removes the model.h/model.cc NumRequiredArguments table and the
CheckSolution / FillConstraint dispatch guards. Net reduction in code.
@evilgensec

Copy link
Copy Markdown
Author

Thanks, simplified. Each Check* method now verifies its own argument count at the start and returns unsatisfied if a constraint has too few arguments, so there is no central table to keep in sync. Dropped the NumRequiredArguments table and the CheckSolution dispatch check; net reduction in code.

Agreed a correct MiniZinc compiler should not emit invalid .fzn. The guard is defense-in-depth for the standalone case: the solver/checker can be pointed at any .fzn (hand-written, third-party, or from another tool), where today a too-short constraint is an out-of-bounds read rather than a clean rejection.

One open point: the CP-SAT extractor (FillConstraint and the per-type methods) also indexes arguments directly, and I dropped its guard along with the table. If you would prefer the loader to fail gracefully on a malformed .fzn, I can add the same per-method checks there; otherwise I will leave it relying on the parser. Which do you prefer?

@lperron

lperron commented Jun 18, 2026 via email

Copy link
Copy Markdown
Collaborator

Per review, assert the argument-count invariant at the start of each
extractor method before it indexes fz_ct.arguments. A malformed FlatZinc
constraint with too few arguments should have been rejected earlier, so
this is a debug-build invariant (DCHECK) rather than runtime handling.
@evilgensec

Copy link
Copy Markdown
Author

Done. Added a DCHECK at the start of each extractor method that indexes arguments, asserting the arity it needs before reading, rather than a single check in FillConstraint since the dispatch is generic and has no per-type arity now that the table is gone. Used DCHECK_GE on the minimum each method indexes so it stays a debug-only invariant and will not trip on constraints that carry extra arguments. Happy to switch to DCHECK_EQ on exact arity if you prefer.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants