Skip to content

console: fix vfs tail follow-mode use-after-free on session disconnect#1424

Open
kezarjg wants to merge 2 commits into
openvehicles:masterfrom
kezarjg:console-vfs-tail-uaf
Open

console: fix vfs tail follow-mode use-after-free on session disconnect#1424
kezarjg wants to merge 2 commits into
openvehicles:masterfrom
kezarjg:console-vfs-tail-uaf

Conversation

@kezarjg

@kezarjg kezarjg commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

vfs tail <file> is a documented VFS command (user guide; the CAN-logging guide also shows using tail to follow a growing log). With no line count it enters follow mode by default — "[tail: in follow mode, press Ctrl-C to abort]" — running its Service() loop on its own task, writing to the launching session's OvmsWriter via a raw pointer. The only stop path is the Ctrl-C Terminator callback.

If the SSH/Telnet session closes or drops by any means other than Ctrl-C, the console (OvmsWriter) is destroyed while the task keeps running; the next writer->write() dereferences the freed object → LoadProhibited.

Decoded backtrace (two separate occurrences):

LoadProhibited, EXCCAUSE 0x1c, EXCVADDR 0x10, A8=0, task "tail"
VfsTailCommand::Service()  main/ovms_vfs.cpp:756  -> writer->write(buf, len);
TaskBase::Task(void*)      main/task_base.cpp:161

EXCVADDR 0x10 / A8=0 = virtual call through a freed (zeroed) vtable.

Fix: OvmsWriter tracks its single active follow-mode task; TerminateCommandTask() sets OCS_StopRequested and waits for the task to exit and release the writer before the transport is freed. Called from ~ConsoleSSH/~ConsoleTelnet, with a backstop in ~OvmsWriter(). Cooperative stop+join (no vTaskDelete) so the task is never force-killed mid-write. Generic to any OvmsCommandTask.

Teardown briefly blocks the close handler until the task exits (≤ one ~250 ms poll); a concurrent Ctrl-C during teardown isn't possible on the disconnect path.

Tested on hw3.3-5 (Solterra): built, flashed, repeatedly dropped an SSH session mid-vfs tail → no crash; normal Ctrl-C exit and one-shot vfs tail -N unaffected.

@dexterbg

dexterbg commented Jun 5, 2026

Copy link
Copy Markdown
Member

The fix applies at the wrong level / from the wrong direction.

The potential necessity of a clean termination for running commands from a session disconnect applies not only to running command tasks, but to generally all interactive commands started within a shell. There is no requirement for these to use an OvmsCommandTask instance, the common property of interactive commands is to register an insert callback.

Most commands registering an insert callback do not need any cleanup though, as they don't allocate any resources (e.g. simple y/n listeners like module factory reset). Other interactive commands besides vfs tail needing a cleanup would for example be enable and vfs edit.

So what's needed here is either an extension of the insert callback handler scheme for passing a termination request (e.g. by introducing a specific session termination character code), or a dedicated registry for a command agnostic termination handler similar to the insert callback registry.

Side note: don't add fixes to changes.txt, except if they need users to change something on their side.

Regards,
Michael

…connect

An interactive command that has taken over the session input (by registering an
insert callback) may keep resources, or a background task, bound to the
launching session's OvmsWriter. If the SSH/Telnet session is closed or dropped
by any means other than the command's normal exit, the writer (and the transport
it points to) was freed while the command still referenced it:

  - "vfs tail <file>" in follow mode (OCS_RunLoop) runs its Service() loop on its
    own task, writing through the raw OvmsWriter*. The next writer->write() after
    the console was freed dereferenced a zeroed vtable -> LoadProhibited crash
    (EXCVADDR 0x10, task "tail", VfsTailCommand::Service()).
  - "vfs edit" and "enable" leaked their heap-allocated state (editor_state /
    PasswordContext) when the session dropped mid-input.

Add a generic termination-handler registry to OvmsWriter, parallel to the
insert-callback registry: an interactive command registers a termination handler
alongside its insert callback, and a console invokes RunTerminationCallback()
during teardown -- before freeing any transport it owns -- so the handler can
release resources / stop background tasks while the writer is still valid. At
most one handler is active at a time, mirroring the single active insert
callback.

  - OvmsCommandTask registers a handler that requests its task to stop and joins
    it (cooperative stop+join, no vTaskDelete) before the writer is freed; covers
    any follow-mode command, not just vfs tail.
  - vfs edit / enable register handlers that free their allocations.
  - The y/n insert callbacks with no allocated state (module factory reset, OTA
    partition prompts, test echo) need no handler.

Called from ~ConsoleSSH/~ConsoleTelnet before they free their transport, from
~OvmsConsole before it frees the shared queue (covers the Bluetooth console),
with a final backstop in ~OvmsWriter.

Replaces the earlier OvmsCommandTask-specific approach per review: the shared
abstraction for an interactive command owning the session is the insert
callback, not OvmsCommandTask.
@kezarjg kezarjg force-pushed the console-vfs-tail-uaf branch from f1b0214 to 7778bf1 Compare June 5, 2026 10:26
@kezarjg

kezarjg commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Reworked along your second suggestion — a dedicated termination-handler registry on OvmsWriter, parallel to the insert-callback registry, rather than the OvmsCommandTask-specific path.

RegisterTerminationCallback(cb, ctx) / DeregisterTerminationCallback(cb) mirror the insert-callback registry; an interactive command registers a termination handler alongside its insert callback. Consoles call 'RunTerminationCallback() during teardown — before freeing the transport they own — so the handler can release resources / stop tasks while the writer is still valid (~ConsoleSSH/~ConsoleTelnetbefore freeing their transport,~OvmsConsolebefore freeing the shared queue so the Bluetooth console is covered too, and a backstop in~OvmsWriter`).

Handlers wired up:

  • OvmsCommandTask — requests its follow-mode task to stop and joins it (cooperative, no vTaskDelete); generic to any OvmsCommandTask, not just vfs tail.
  • vfs edit and enable — free their editor_state / PasswordContext, fixing the disconnect leaks you pointed out (and they deregister on normal completion to avoid a double-free).
  • The y/n insert callbacks with no allocated state (factory reset, OTA prompts) register nothing, as you noted.

Also dropped the changes.txt entry.

Compiles clean; I'll test on hardware (hw3.3-5 Solterra) this weekend — repeating the mid-vfs tail SSH-disconnect case plus normal Ctrl-C / one-shot tail -N, and disconnects during vfs edit / enable — and report back.

@kezarjg kezarjg marked this pull request as draft June 5, 2026 10:41
…nd-reap)

The termination-registry cleanup joined the follow-mode command task
synchronously during ~ConsoleSSH. But ConsoleSSH::write() takes the Mongoose
lock that the teardown thread already holds (it runs on the Mongoose task,
under that lock for the whole mg_mgr_poll), so dropping a session mid "vfs tail"
deadlocked and wedged the SSH server.

Instead, when an SSH connection closes with a follow-mode task still bound:
mark the console closing (so its in-flight write()/SendCallback bail without
touching the now-freed connection), ask the task to stop WITHOUT joining, and
defer the console's destruction to a reap pass on the listener poll once the
task has left the write path. SendCallback is re-keyed off the ConsoleSSH*
(not the mg_connection) so it can never dereference a freed connection.

The shared termination-handler join is unchanged for Telnet/Async/Bluetooth,
which do not take the Mongoose lock in their send path.

On-vehicle validated (hw3.3-5 Solterra): repeated abrupt SSH disconnects during
an active "vfs tail" no longer wedge or crash the module; free heap flat over
15 cycles (no leak); idle tail, Ctrl-C abort and one-shot "vfs tail -N" unaffected.
@kezarjg

kezarjg commented Jun 6, 2026

Copy link
Copy Markdown
Contributor Author

I've pushed a revision (it's the current PR head). Short version: while validating the rework on-vehicle I found the previous approach deadlocks on SSH, so I reworked the teardown to fix it. Wanted to walk you through it plainly and get your read on a couple of design calls, since it's in the area you flagged.

Your review points are all addressed. The cleanup is now a generic termination-handler registry, not OvmsCommandTask-specific: any interactive command that takes over the session registers a handler. enable and vfs edit register one (they free their prompt/editor state); the simple y/n listeners like module factory reset allocate nothing and register nothing. I took the dedicated command-agnostic registry option of the two you suggested, and dropped the changes.txt entry.

The deadlock (new; surfaced during testing)
The follow-mode cleanup stopped the command task and then waited for it to exit before freeing the transport. On SSH that's creates a deadlock: ~ConsoleSSH runs on the Mongoose task, which holds the Mongoose lock for the whole mg_mgr_poll(), while the vfs tail task's write()SendCallback needs that same lock. So the teardown waits forever for the task, and the task waits forever for the lock. The SSH server wedges and only a power cycle recovered it. Dropping a session mid-vfs tail reproduced it every time once the log was actively growing (a static/idle file didn't).

The fix; "signal and reap" instead of "stop and wait"
When an SSH connection closes with a follow-mode task still bound, I now:

  • mark the console closing, so the task's in-flight write() bails out immediately without taking the lock or touching the (about-to-be-freed) connection;
  • ask the task to stop, but don't wait for it; and
  • defer freeing the console. A reap pass on the listener's poll cleans it up once the task has actually exited.

SendCallback is also re-keyed off the ConsoleSSH* rather than the mg_connection, so it can never dereference a connection Mongoose has already freed. I kept this SSH-local on purpose: Telnet/USB/Bluetooth keep the existing join, since their send paths don't take the Mongoose lock and don't deadlock.

Validated on-vehicle
(hw3.3‑5 Solterra): abrupt SSH disconnects during an active vfs tail now survive every time (it wedged 100% before); free heap is flat over 15 disconnect cycles (the reap frees the deferred consoles; no session-slot leak); and Ctrl‑C abort, one-shot vfs tail -N, and disconnects during enable / vfs edit / module factory reset are all unaffected.

Two calls I'd value your view on:

  1. SSH-local vs. general. I scoped the defer-and-reap to the SSH console because it's the only transport that actually deadlocks; the termination-handler registry itself stays generic, as you suggested. I'm happy to generalize the deferral across transports if you'd rather it be uniform.
  2. Reap placement. I run it from the listener's MG_EV_POLL. A NetManager ticker or a shared console reaper would work equally well — open to your preference.

(Longer term, routing SSH output through a bounded queue the way ConsoleAsync does would mean a follow task never touches the Mongoose lock at all. This change is compatible with that direction but doesn't require it.)

@kezarjg kezarjg marked this pull request as ready for review June 6, 2026 10:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants