darktable-org / darktable-org/darktable
liquify: _start_new_shape() dereferences an unchecked allocation and segfaults on a full path
Nobody has claimed this yet.
- Dominant language
- C
- Stars
- 13.1k
- Forks
- 1.4k
- Avg merge
- 22h 14m
- Merged PRs (30d)
- 198
Description
Found by code analysis, 2026-09-03.
Severity: medium. A NULL dereference in the darkroom GUI thread, reproducible on
demand once an instance holds MAX_NODES nodes. Not a race: no threading, no timing
window.
Summary
liquify stores its path in a fixed array of 100 nodes inside the module params
(nodes[MAX_NODES], src/iop/liquify.c:256; MAX_NODES is 100 at :47). The
allocator node_alloc() (:342-355) scans that array for an invalidated slot and
returns NULL when there is none, and the three wrappers alloc_move_to(),
alloc_line_to() and alloc_curve_to() (:2631, :2646, :2661) pass that NULL
through.
_start_new_shape() writes through the returned pointer without checking it:
3198: g->temp = alloc_move_to(self, pt);
3199: g->temp->warp.radius = pt + radius;
3200: g->temp->warp.strength = pt + r * cexpf(phi * I);
Every other allocation site in the file checks. When the path is full, :3199 writes to
a fixed offset from NULL (36 bytes on x86-64 gcc, the offset of warp.radius inside
dt_liquify_path_data_t, :206-244), which is inside the unmapped zero page: darktable
dies with SIGSEGV rather than corrupting anything.
How a user reaches it
Two paths, both ordinary use of the tool buttons.
Placing the 100th point with continuous creation on (ctrl+click on a shape tool sets
g->creation_continuous, :3548). The preview node and the committed points share the
array: after the Nth point is committed, slots 0..N-1 hold points and the preview holds
slot N. Committing the 100th point consumes the last slot, and button_released() then
starts the next preview:
3236: g->temp = NULL; // a point is done
3237:
3238: if(g->creation_continuous)
3239: _start_new_shape(self);
Clicking any shape tool with a full path. _btn_make_radio_cb() releases a slot
first only if a preview is live:
3555: if(g->status & DT_LIQUIFY_STATUS_PREVIEW)
3556: {
3557: node_delete(p, g->temp);
...
3602: _start_new_shape(self);
After the last shape was completed the PREVIEW bit is clear (button_pressed() clears
it at :3136 and :3170), so nothing is freed and :3602 runs into the same
dereference. _btn_make_radio_cb(NULL, ...) from gui_focus() / gui_reset() returns
at :3573, before this, and is not affected.
The third caller, the cancel path at :3299, is safe by accident: it is preceded by
node_delete(p, g->temp) at :3296, which frees the slot the new shape then takes.
Why this is not a judgement call
The file checks this allocator at every other call site, and button_pressed() is
already written to cope with g->temp == NULL:
| site | code |
|---|---|
:3250-3251 |
g->temp = alloc_line_to(self, pt); / if(!g->temp) goto done; |
:3266-3267 |
g->temp = alloc_curve_to(self, pt); / if(!g->temp) goto done; |
:3382-3383 |
dt_liquify_path_data_t *curve2 = alloc_curve_to(self, 0); / if(!curve2) goto done; |
:3417-3418 |
dt_liquify_path_data_t *tmp = alloc_line_to(self, e->warp.point); / if(!tmp) goto done; |
:3134, :3161 |
if(!g->temp) goto done; in button_pressed() |
So a full path is an anticipated state everywhere except :3198. The consequence of the
omission is not a degraded shape, it is the process dying.
Suggested fix
Match the file's own convention:
g->temp = alloc_move_to(self, pt);
if(!g->temp) return;
g->temp->warp.radius = pt + radius;
Returning early leaves g->temp NULL and the PREVIEW bit clear, which is exactly the
state the checked sites above leave behind, and which button_pressed() already handles.
The tool button stays active and nothing happens on the canvas.
Worth deciding separately, not part of the crash fix: a full path currently fails
silently at all five sites — no dt_control_hinter_message(), no log line — so the user
sees the tool stop responding with no explanation. Telling them the shape is full would
be a UI change on its own.
Related reports
- A companion
liquifyreport coversg->draggingsurvivingnode_delete(p, g->temp)
at:3557, from the same validation row set. The two are independent: this one is a
missing NULL check, that one a missingend_drag(). - The corpus has no other
liquifyreport. Not checked against upstream — no
ghin this environment, so a pre-existing GitHub issue for this crash cannot be
ruled out.
Environment
Verified line by line against 3c73bf2aaa (branch dt-lockcheck; src/ matches the
2026-09-03 upstream rebase). The struct offset was measured by compiling the four type
definitions from :206-244 standalone with the system gcc; everything else is reading.
Static analysis only — the crash was not reproduced at runtime.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/iop/liquify.c at _start_new_shape() and compare its alloc_move_to() handling with the checked allocation sites in button_pressed(). Confirm the full-path cases described in the issue leave g->temp NULL without dereferencing it, and verify the file still builds successfully.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c
- Domain
- desktop
- Issue type
- Bug
- Difficulty
- 1/5
- Estimated time
- Under an hour
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100