Files
wadamesh/scripts/build/patch_lvgl_anim_uaf.py
Kaj SchittecatandClaude Opus 5 2f6984443d touch: fix the chat page-scroll panic on long chats (#428, #475)
Pressing down past the last message page-scrolls the chat list with an
LVGL animation. In a long chat the list uses compressed scroll
coordinates, and chatVirtRemap1To1Scroll re-anchored the position on
every LV_EVENT_SCROLL with lv_obj_scroll_to_y(LV_ANIM_OFF). That deletes
the running animation from inside its own step. LVGL 8.4 reads the
animation again after the step, and when the step was also its last
(the UI loop was busy for longer than the animation) it finished the
freed animation and freed it a second time. By the next round that
memory belonged to something else, and anim_timer called a garbage
get_value_cb: the jump to 0x00020000 in the beta_79 and beta_80 M9 dumps,
both at the same call site.

- chatVirtRemap1To1Scroll follows a scroll that an animation drives and
  leaves the re-anchor to chatVirtOnScrollEnd. The page scroll now also
  completes on long chats; the early re-anchor stopped it after a frame.
- scripts/build/patch_lvgl_anim_uaf.py skips LVGL's completion check when
  the animation list changed during the step, as LVGL 9 does. Applied to
  every touch env as a pre-script and to the vendored P4 copy
  (fetch-deps.sh, build.sh). Idempotent, fails closed on source drift.
- test/lvgl_anim_uaf/run.sh reproduces it on the host under
  AddressSanitizer: stock LVGL with the old handler reports the
  use-after-free in anim_timer, and either fix alone runs clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:43:14 +02:00

218 lines
7.7 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
# LVGL 8.4's anim_timer() reads the animation again after calling its exec_cb:
#
# if(a->exec_cb) a->exec_cb(a->var, new_value);
# ...
# if(a->act_time >= a->time) anim_ready_handler(a);
#
# exec_cb can delete that animation. A scroll animation's exec_cb sends
# LV_EVENT_SCROLL, and any handler that then scrolls the same object without
# animation (lv_obj_scroll_to_y(..., LV_ANIM_OFF)) deletes it through
# lv_anim_del(). When that step was also the animation's last one, LVGL finishes
# the freed animation: it runs the ready callback on freed memory and frees it a
# second time. By then the block usually belongs to something else, so the
# second free releases a live allocation. This is the ThinkNode M9 chat-scroll
# panic in issues #428 and #475: a later animation reused that memory and
# anim_timer jumped to 0x00020000.
#
# The loop already records any list change made during this animation's
# callbacks in anim_list_changed, and restarts from the list head when it is
# set. LVGL 9 skips the completion check in that case (the animation, if it
# still exists, completes on the next round), and this patch does the same.
#
# Idempotent and fail-closed, like patch_meshcore_txt_timestamp.py. On a fresh
# checkout pre-scripts run before PlatformIO fetches lib_deps, so the pre-link
# action patches the newly fetched source and fails that first link. Re-running
# then compiles the patched source. The IDF builds (Tanmatsu, T-Display P4)
# vendor LVGL through tanmatsu/fetch-deps.sh and apply this with --patch-file.
import os
import sys
MARKER = "wadamesh-lvgl-anim-uaf-patch"
OLD = """ /*If the time is elapsed the animation is ready*/
if(a->act_time >= a->time) {
anim_ready_handler(a);
}"""
NEW = """ /*If the time is elapsed the animation is ready.
*wadamesh-lvgl-anim-uaf-patch: exec_cb may have deleted `a` (a scroll
*handler that scrolls the same object without animation does), so do not
*read it when the list changed. It completes on the next round (LVGL 9).*/
if(!anim_list_changed && a->act_time >= a->time) {
anim_ready_handler(a);
}"""
REQUIRED_LINES = (
"static bool anim_list_changed;",
" anim_list_changed = false;",
" if(a->exec_cb) a->exec_cb(a->var, new_value);",
" if(anim_list_changed)",
)
def patch_source(source):
if any(source.count(line) != 1 for line in REQUIRED_LINES):
raise RuntimeError(
"lv_anim.c anim_timer() does not match LVGL 8.4 (LVGL version drift?)"
)
old_count = source.count(OLD)
new_count = source.count(NEW)
marker_count = source.count(MARKER)
if old_count == 0 and new_count == 1 and marker_count == 1:
return source, False
if old_count != 1 or new_count != 0 or marker_count != 0:
raise RuntimeError(
"lv_anim.c completion check does not match LVGL 8.4 (LVGL version drift?)"
)
patched = source.replace(OLD, NEW, 1)
if patched.count(OLD) != 0 or patched.count(NEW) != 1 or patched.count(MARKER) != 1:
raise RuntimeError("lv_anim.c patch verification failed")
return patched, True
def patch_file(path):
with open(path, encoding="utf-8") as source_file:
source = source_file.read()
patched, changed = patch_source(source)
if changed:
with open(path, "w", encoding="utf-8") as source_file:
source_file.write(patched)
return changed
def verify_source(source):
_, changed = patch_source(source)
if changed:
raise RuntimeError("lv_anim.c still finishes animations deleted by their exec_cb")
def verify_file(path):
with open(path, encoding="utf-8") as source_file:
verify_source(source_file.read())
def self_test():
fixture = "\n".join(REQUIRED_LINES[:3]) + "\n" + OLD + "\n" + REQUIRED_LINES[3] + "\n"
patched, changed = patch_source(fixture)
assert changed
assert MARKER in patched
assert "if(!anim_list_changed && a->act_time >= a->time) {" in patched
assert " if(a->act_time >= a->time) {" not in patched
same, changed = patch_source(patched)
assert not changed
assert same == patched
verify_source(patched)
try:
verify_source(fixture)
except RuntimeError:
pass
else:
raise AssertionError("verification must reject an unpatched source")
try:
patch_source("static void anim_timer(lv_timer_t * param) {}\n")
except RuntimeError:
pass
else:
raise AssertionError("version drift must fail closed")
for invalid in (fixture + OLD, patched + OLD, "/* " + MARKER + " */\n" + fixture):
try:
patch_source(invalid)
except RuntimeError:
pass
else:
raise AssertionError("ambiguous or poisoned patch state must fail closed")
print("patch_lvgl_anim_uaf self-test passed")
def install(platformio_env):
path = os.path.join(
platformio_env.subst("$PROJECT_LIBDEPS_DIR"),
platformio_env.subst("$PIOENV"),
"lvgl",
"src",
"misc",
"lv_anim.c",
)
def apply_or_error():
if not os.path.isfile(path):
return "LVGL patch target is missing: %s" % path
try:
changed = patch_file(path)
except (OSError, RuntimeError) as error:
return str(error)
print(
"[patch_lvgl_anim_uaf] %s"
% ("patched lv_anim.c" if changed else "already patched")
)
return None
if os.path.isfile(path):
error = apply_or_error()
if error is not None:
print("[patch_lvgl_anim_uaf] ERROR: %s" % error)
platformio_env.Exit(1)
else:
print(
"[patch_lvgl_anim_uaf] LVGL not fetched yet - "
"patch deferred to pre-link check"
)
def verify_patched(target, source, env):
del target, source
if os.path.isfile(path):
try:
verify_file(path)
return 0
except (OSError, RuntimeError):
pass
error = apply_or_error()
if error is not None:
print("[patch_lvgl_anim_uaf] ERROR: %s" % error)
return 1
print("[patch_lvgl_anim_uaf] ERROR: LVGL was patched after compilation")
print("[patch_lvgl_anim_uaf] ERROR: re-run `pio run` to compile the fix")
return 1
platformio_env.AddPreAction("$BUILD_DIR/${PROGNAME}.elf", verify_patched)
try:
Import("env") # noqa: F821 - provided by PlatformIO/SCons
except NameError:
env = None
if env is not None:
install(env)
if env is None and __name__ == "__main__":
if len(sys.argv) == 2 and sys.argv[1] == "--self-test":
self_test()
elif len(sys.argv) == 3 and sys.argv[1] == "--patch-file":
try:
changed = patch_file(sys.argv[2])
print(
"patch_lvgl_anim_uaf: %s"
% ("patched" if changed else "already patched")
)
except (OSError, RuntimeError) as error:
print("patch_lvgl_anim_uaf: ERROR: %s" % error, file=sys.stderr)
raise SystemExit(1)
elif len(sys.argv) == 3 and sys.argv[1] == "--verify-file":
try:
verify_file(sys.argv[2])
print("patch_lvgl_anim_uaf: verified")
except (OSError, RuntimeError) as error:
print("patch_lvgl_anim_uaf: ERROR: %s" % error, file=sys.stderr)
raise SystemExit(1)
else:
raise SystemExit(
"usage: patch_lvgl_anim_uaf.py "
"(--self-test | --patch-file PATH | --verify-file PATH)"
)