Repository navigation
Fix double-free in XML parse error paths and harden allocation failure handling - #323
Open
bg0d-droid wants to merge 2 commits into
Open
bg0d-droid wants to merge 2 commits into
bg0d-droid wants to merge 2 commits into
Conversation
…e handling node_from_xml transferred ownership of a freshly created node to the tree (plist_dict_set_item / plist_array_append_item / *plist = subnode) but only cleared `subnode` at the very end of the block. When an error occurred in between - e.g. the node_path_item allocation failing right after the root object was assigned to *plist - err_out freed the node while the tree still referenced it, and plist_free(*plist) freed it again: "free(): double free detected in tcache 2". Transfer ownership explicitly at each attach point so err_out can never free a node the tree owns. Allocation failures were also handled inconsistently across the codebase: every caller of byte_array_new, ptr_array_new and hash_table_new already checks for NULL and reports PLIST_ERR_NO_MEM (e.g. plist_from_bin, plist_to_bin), but the constructors dereferenced the malloc result before returning, making those checks unreachable dead code and crashing instead. Same for hash_table_insert and the text_part_append/text_parts_get_content paths in xplist.c, which used assert() (compiled out with NDEBUG) or nothing at all. All of these now fail cleanly and propagate PLIST_ERR_NO_MEM. Additionally: - Guard size computations against integer overflow: byte_array growth (page rounding and total capacity), ptr_array allocation/realloc sizes, and the UTF-16/UTF-8 conversion buffers in bplist.c. - Fix a wrong bounds check in the XML base64 write path: the assert compared the raw input count instead of the encoded output size (4*ceil(count/3) plus terminating NUL) and was compiled out in release builds; it is now a real runtime check. - Check the remaining unchecked malloc(64)/malloc(slen+1) allocations in the OpenStep and JSON engines. Verified with a malloc fault-injection sweep (250 failure points x 2 conversion directions x plist inputs): unpatched code crashes with SIGSEGV/SIGABRT at 40 failure points, patched code has zero signal deaths and reports clean errors. Full test suite (43 tests) passes under both a normal and an ASan+UBSan build.
The plist created in main() was never freed, causing LeakSanitizer failures when running the test suite under ASan.
bg0d-droid
force-pushed
the
harden-oom-handling-double-free
branch
from
October 14, 2026 05:23
81b876f to
0108118
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR fixes a double-free reachable from XML parsing and systematically hardens allocation-failure handling across the library, replacing
assert()-based error handling and unchecked size arithmetic with proper error propagation.1. Double-free in
node_from_xml(src/xplist.c)node_from_xmltransfers ownership of a freshly created node to the tree (plist_dict_set_item/plist_array_append_item/*plist = subnode), but only clearedsubnodeat the very end of the block. When an error occurred in between — concretely, thenode_path_itemallocation failing right after the root object was assigned to*plist—err_outfreed the node while the tree still referenced it, andplist_free(*plist)then freed it again:Reproduced with a malloc fault-injection harness (see Verification below): parsing a small XML plist with the failure landing on that allocation aborts on unpatched master with the message above; the core trace shows
plist_free→plist_free_node→node_destroy→free()aborting during theerr_outcleanup at xplist.c:1618.The fix transfers ownership explicitly (
subnode = NULL) at each attach point, soerr_outcan never free a node the tree owns. The two error paths after attach also now reportPLIST_ERR_NO_MEMinstead ofPLIST_ERR_PARSEwhen the allocation fails.2. Allocation failures crash before callers can handle them
Every caller of
byte_array_new,ptr_array_newandhash_table_newalready checks the result for NULL and reportsPLIST_ERR_NO_MEM— e.g.plist_from_bin:But the constructors dereference the
mallocresult before returning, so those checks are unreachable dead code and the library crashes instead. The constructors now honor the contract every caller already assumes, inbytearray.c,ptrarray.c,hashtable.c(bothhash_table_newandhash_table_insert).The same inconsistency existed in the XML engine:
text_part_appendandtext_parts_get_contentguarded allocations withassert()(compiled out entirely withNDEBUG, so release builds write through NULL) or nothing at all. Both now return NULL cleanly andget_text_partspropagatesPLIST_ERR_NO_MEM. The remaining uncheckedmalloc(64)/malloc(slen+1)allocations in the OpenStep and JSON engines are checked as well.3. Integer-overflow guards on size arithmetic
byte_array_new/byte_array_grow: page-size rounding andcapacity + increaseare now checked before use; overflow puts the array into the same well-defined failed state as OOM.byte_array_append: guardslenagainstSIZE_MAX - ba->len.ptr_array_new/ptr_array_insert: allocation and realloc sizes checked forintandsize_toverflow.bplist.c: the UTF-16→UTF-8 (4*(len+1)) and UTF-8→UTF-16 (((size*2)+1)*sizeof(uint16_t)) conversion buffer computations are checked against overflow.4. Wrong bounds check in the XML base64 write path
The buffer guard for
base64encodeoutput was:countis the raw input byte count;base64encodewrites4*ceil(count/3)bytes plus a terminating NUL. The assert checked the wrong quantity and, compiled out underNDEBUG, left the only bounds check on that write path missing entirely. It is now a real runtime check against the actual encoded size, growing the buffer or returningPLIST_ERR_NO_MEM.Verification
Fault-injection sweep — an
LD_PRELOADinterposer failing the k-th allocation (armed frommain()), swept over 250 failure points × 2 conversion directions × XML/binary/JSON/OpenStep inputs (419 runs per build):All failure points now produce clean
PLIST_ERR_NO_MEMerrors.Test suite — all 43 tests pass on a normal build and under
-fsanitize=address,undefined. (The suite initially had one pre-existing leak intest/integer_set.cunder LeakSanitizer, fixed in the second commit.)Formatting — the project runs clang-format via pre-commit; touched hunks follow each file's existing style.
Security impact
The double-free is memory corruption in a parser that routinely processes untrusted input (mobile device backups, plists from the network). Beyond that specific fix, the patch eliminates the entire error-prone pattern:
assert()as the only guard on allocation results (disappears in release builds), unchecked allocation returns that their callers already expected to check, and unchecked size arithmetic in growth/conversion paths — so allocation failure now degrades to a clean error instead of undefined behavior.