Skip to content

Fixed out-of-bounds read in string_replace with nested list elements - #6483

Open
Dhanu-dynamic wants to merge 1 commit into
cfengine:masterfrom
Dhanu-dynamic:string-replace-list-element-type
Open

Dhanu-dynamic wants to merge 1 commit into
cfengine:masterfrom
Dhanu-dynamic:string-replace-list-element-type

Conversation

@Dhanu-dynamic

Copy link
Copy Markdown
Contributor

string_replace(string, match, substitute, "lists") passes every element of the match and
substitute arrays to JsonPrimitiveGetAsString() in StringReplaceWithLists(). For a primitive
that returns primitive.value, but for a nested array or object that field aliases
container.children (a Seq *), so the internal pointer comes back as a char * and
SearchAndReplace() reads it as a string. FnCallStringReplace() only checks that both arguments
are arrays of equal length, not that the elements are primitives, so a nested element reaches the
call. The lists are usually built from data containers (readjson, parsejson, mergedata,
module output), so JSON carrying a nested element reaches it.

In a release build (NDEBUG) this is an out-of-bounds heap read whose bytes, including internal
pointers, are copied into the returned string, and it can crash depending on the heap layout. In a
debug build the assert in JsonPrimitiveGetAsString() aborts instead, which cf-promises hits
while validating the policy.

The fix rejects any non-primitive element in StringReplaceWithLists() before the conversion,
using the warn-and-fail path the function already has.

Repro: string_replace("abcb", '["b"]', '[["Z"]]', "lists"). With json.c built -DNDEBUG and
ASan the result contains heap bytes where b was; in the normal debug build cf-promises -c
aborts in JsonPrimitiveGetAsString. The new unit test in evalfunction_test.c aborts before the
change and passes after, and checks that a valid flat-list replace still returns aZcZ.

StringReplaceWithLists(), the string_replace(..., "lists") policy
function, passes each element of the match and substitute arrays
directly to JsonPrimitiveGetAsString():

    const char *m = JsonPrimitiveGetAsString(JsonAt(match_list, i));
    const char *s = JsonPrimitiveGetAsString(JsonAt(substitute_list, i));

That function returns primitive.value, which in the JsonElement union
aliases container.children (a Seq *). For an element that is itself an
array or object it therefore returns the internal container pointer
reinterpreted as a char *, and SearchAndReplace() then reads it as a
NUL-terminated string. FnCallStringReplace() only checks that the two
arguments are arrays of equal length, not that every element is a
primitive, so a nested element reaches the unchecked call. These lists
commonly come from data containers (readjson, parsejson, mergedata,
module output), so JSON with a nested element triggers it.

With NDEBUG the read runs off the Seq object into adjacent heap memory
and those bytes, including internal pointers, end up in the returned
string; otherwise the assertion in JsonPrimitiveGetAsString() aborts,
which cf-promises hits during policy validation.

Reject any non-primitive element before the conversion, reusing the
function's existing warn-and-fail path.

Changelog: Title
Signed-off-by: Dhanraj E <dhanraj@labs.digiscrypt.com>
@cf-bottom

Copy link
Copy Markdown

Thank you for submitting a PR! Maybe @craigcomstock can review this?

@larsewi
larsewi requested review from larsewi and victormlg November 8, 2026 13:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants