darktable-org / darktable-org/darktable

liquify: _start_new_shape() dereferences an unchecked allocation and segfaults on a full path

Open Beginner friendly
#22,134 0 comments 0 reactions 0 assignees View on GitHub

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 liquify report covers g->dragging surviving node_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 missing end_drag().
  • The corpus has no other liquify report. Not checked against upstream — no
    gh in 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.