Skip to content

fix(microlink): worker task liveness bitmask, orphan-parked teardown - #47

Merged
fudio101 merged 2 commits into
mainfrom
fix/teardown-uaf
Aug 18, 2026
Merged

fix(microlink): worker task liveness bitmask, orphan-parked teardown#47
fudio101 merged 2 commits into
mainfrom
fix/teardown-uaf

Conversation

@fudio101

Copy link
Copy Markdown
Collaborator

What

Replaces microlink_stop()'s fixed 3s vTaskDelay with a real join, using a
per-task liveness bitmask (ML_TASK_BIT_NET_IO/DERP_TX/COORD/WG_MGR):

  • Each of the 4 core tasks sets its bit before xTaskCreatePinnedToCore()
    and clears it as the very last thing it does, via the new ml_task_exit(),
    before vTaskDelete(NULL).
  • A zero mask is proof no worker can touch the context again — that's what
    microlink_destroy() now gates the free on. A worker wedged in DNS or a
    TLS handshake on a captive-portal network routinely outlived the old fixed
    sleep, then woke up to a freed context (queues, event group already
    reused) — a use-after-free crash observed on real hardware.
  • If a worker hasn't joined within ML_STOP_JOIN_TIMEOUT_MS (10s), the
    context is parked in an orphan list instead of freed, and reclaimed later
    by the new microlink_reap_orphans() (called automatically from
    microlink_init() / microlink_destroy()) once the straggler finally
    exits.
  • microlink_start() gets a fail_start rollback path: if a task creation
    fails partway through, the tasks that did come up are joined via
    microlink_stop() instead of being abandoned as future orphans. Also logs
    free internal/PSRAM heap at the failure point for diagnostics.
  • ml_shutdown_pending() checks were added at the 3 places a worker can
    block for seconds during DERP connect/reconnect (the two retry loops in
    ml_derp_tx_task, and the TLS handshake WANT_READ/WANT_WRITE spin in
    ml_derp_connect()), so teardown doesn't have to wait out a full DNS or
    TLS timeout.

Also fixes a real bug in ml_derp_connect(): the mbedTLS ssl/ssl_conf
structs were re-initialized on every connect/retry without freeing the
previous attempt's state first — orphaning heap allocations that mbedTLS's
dynamic-buffer plumbing still held pointers to.

Source

Adapted (not cherry-picked — this fork's base has diverged too far from
upstream for a literal git cherry-pick) from two commits on
cplewes/microlink:

  • a415d646 — the liveness bitmask / orphan-park mechanism. Ported directly;
    our microlink.c/microlink_internal.h structure at this point matched
    cplewes's closely enough for a near-verbatim port.
  • 7120dfa4 — by diff inspection this came before a415d646 in cplewes's
    own history and added an ad-hoc ml->events-NULL-guard + fail_start
    approach; a415d646 is built directly on top of it and replaces most of
    those guards with the proper bitmask (its own comment in ml_coord.c says
    the leftover NULL check is "belt-and-braces... used to be the only
    guard"). The one piece of 7120dfa4 not superseded — the DERP mbedTLS
    double-init fix — is included here as its own, distinct change.

Our fork's ml_derp_conn_t has no entropy/ctr_drbg fields (RNG is
PSA-owned post mbedTLS 4.x/TF-PSA-Crypto migration, see
ESP_IDF_6X_COMPAT.md), so the double-init fix frees only ssl/ssl_conf
— the same adaptation already used for derp_free_tls_state() in #44.

Scope note

ml_cellular.c, ml_net_switch.c, and ml_udp.c each have their own
vTaskDelete(NULL) call sites, deliberately not touched here — those are
the conditional net_switch/cellular-PPP task and a per-socket helper task,
outside the 4-core-task teardown contract this PR establishes (see
CLAUDE.md's task table).

Related issues

Closes #21
Closes #22

Upstream reference: none — this is fork-mining work, not from
CamM2325/microlink's own PR queue (see UPSTREAM_PRS.md for that
tracking).

Test plan

idf.py build isn't runnable in this sandbox (no ESP-IDF installed). Manual
verification done instead:

  • grep -n "vTaskDelete(NULL)" components/microlink/src/ml_net_io.c components/microlink/src/ml_derp.c components/microlink/src/ml_coord.c components/microlink/src/ml_wg_mgr.c
    returns nothing — all 6 core-task exit points converted to ml_task_exit().
  • Confirmed ml_cellular.c/ml_net_switch.c/ml_udp.c still have their own
    vTaskDelete(NULL), untouched (scope check).
  • Confirmed the new mbedtls_ssl_free/config_free pair at the top of
    ml_derp_connect() never touches derp.sockfd, and the existing
    fail_tls:/derp_free_tls_state() cleanup path (from fix(derp,coord): absorb bugfix/hardening subset of upstream PR #22 #44) still runs on
    every failure path afterward — no double ml_close_sock() on the same fd.
  • Brace-balance checked across every touched file.
  • Needs hardware re-test, flagged explicitly: microlink_stop()/
    destroy() under normal conditions (should behave identically — join
    should complete near-instantly), and ideally a captive-portal or
    DNS-blackhole scenario to exercise the join-timeout/orphan-park path,
    which wasn't reachable in this sandbox.

nguyenndt-qualgo and others added 2 commits August 18, 2026 23:37
Replaces the fixed 3s sleep in microlink_stop() with a real join: each
of the 4 core tasks (net_io, derp_tx, coord, wg_mgr) sets its
ML_TASK_BIT_* before creation and clears it as the last thing it does
before exiting, via the new ml_task_exit(). A zero mask is proof no
worker can touch the context again, which is what microlink_destroy()
now gates the free on — a task wedged in DNS or a TLS handshake on a
captive-portal network routinely outlived the old fixed sleep, and woke
up to a freed context (queues, event group) already reused, producing
UAF crashes on real hardware. A context whose workers haven't joined
by microlink_stop()'s timeout is parked in an orphan list instead of
freed, reclaimed later by microlink_reap_orphans() (now called
automatically from microlink_init()/microlink_destroy()) once its
tasks finish draining.

microlink_start() also gets a fail_start rollback path: if a task
creation fails partway through, the tasks that did come up are joined
via microlink_stop() instead of being abandoned as future orphans.

Also fixes a real double-init leak in ml_derp_connect(): the mbedTLS
ssl/ssl_conf structs were re-initialized on every retry without
freeing the previous attempt's state first, orphaning heap allocations
mbedTLS's dynamic-buffer plumbing still held pointers to.

Adapted (not cherry-picked — base has diverged too far) from
cplewes/microlink@a415d646 (bitmask/orphan mechanism) and
cplewes/microlink@7120dfa4 (double-init fix; the rest of that commit's
NULL-guard approach is superseded by a415d64's bitmask, per its own
follow-up comments). Our fork's ml_derp_conn_t has no entropy/ctr_drbg
fields (RNG is PSA-owned post mbedTLS 4.x migration), so the
double-init fix frees only ssl/ssl_conf — same adaptation already used
for derp_free_tls_state() in #44.

Closes #21
Closes #22

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both landed in one PR since cplewes/microlink@a415d646 (issue #21)
supersedes most of @7120dfa4 (issue #22) — see the PR description for
the full breakdown of what was kept from each commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@fudio101
fudio101 merged commit 6a7d8c5 into main Aug 18, 2026
13 checks passed
fudio101 added a commit that referenced this pull request Aug 18, 2026
…e queue depth (#51)

CONFIG_ML_CONFIG_HTTPD (default y) gates only the port-80 httpd task
start; ml_config_httpd_init() still loads NVS settings regardless, so a
provisioned device is unaffected with it off, saving ~7-8KB of internal
RAM on RAM-tight boards.

ML_PEER_UPDATE_QUEUE_DEPTH was an underived 400, allowing a theoretical
~80KB internal-RAM burst of in-flight peer-update payloads. 32 (4x
runtime max_peers=8) caps that burst at ~6.6KB.

Adapted from antmanler/microlink@6ef9f5a0 — same logic/comments, hand-
ported rather than cherry-picked since the diff no longer applies
cleanly after PR #47's teardown rewrite shifted surrounding lines.

Closes #27

Co-authored-by: Adrian.Nguyen-Qualgo <nguyen.ndt@qualgo.net>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
fudio101 added a commit that referenced this pull request Aug 19, 2026
…hot (#54)

Adapted from Csontikka/microlink@5bda1783 (issue #32), with the
already-fixed active-clearing bug (PR #53, this same session) kept removed
-- the source commit's diff still cleared peer->active right after
wireguardif_connect(), which PR #53 (issues #26/#28) proved permanently
blocks that peer's direct-path handshake since active is the master
session-initiation gate, not a retry throttle. Porting this verbatim would
have silently reintroduced a bug fixed two PRs ago.

process_disco_pong() fired a one-shot direct-path handshake init gated by
a single boolean latch (tried_initial_handshake): if that one init was
lost or unanswered, the peer stayed permanently un-sessioned on the direct
path even though further direct PONGs kept arriving. Replaced the latch
with a retry timestamp (last_init_handshake_ms) so a dropped/unanswered
init gets another try every 30s (INITIAL_HANDSHAKE_RETRY_MS) instead of
giving up forever.

Also documented in FORK_PRS.md that issue #36 (wake blocked sockets on
stop) was investigated and isn't needed here: this fork's
microlink_stop() (from issues #21/#22's PR #47) never frees context until
ml_join_tasks() proves every worker exited, and ml_derp.c's blocking
loops already call ml_shutdown_pending() on every SO_RCVTIMEO-bounded
iteration to bail out cooperatively -- the source commit's UAF doesn't
exist in this fork's architecture.

Closes #32

Co-authored-by: Adrian.Nguyen-Qualgo <nguyen.ndt@qualgo.net>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants