lightningd: avoid race when runtime-added plugins register hooks.
What changed, and why it matters
This patch fixes a race condition in Core Lightning's plugin hook system. If a plugin was added or removed while a hook (like the one that decides whether to accept a payment) was actively being called, the internal list of plugins could be modified mid-iteration. That could cause a plugin to be called twice or skipped entirely, potentially leading to incorrect payment handling or other unexpected behavior. The fix defers updates to the hook list until all current callers are finished.
Treat as a stability and potential security fix. Review whether the deferred-update logic correctly handles all destruction and re-registration edge cases, and consider backporting to maintained branches. No immediate CVE is indicated by the commit message, but the bug could affect payment correctness.
Security signals we found
Race condition in dynamic plugin hook registration
Possible double-call or skip of hook callbacks
Affects htlc_accepted and other plugin hooks
Reference-counted deferred update pattern introduced
Regression test added to demonstrate deferred update
Evidence from the diff
The commit addresses a concurrency bug in lightningd/plugin_hook.c where the hook->hooks tal array could be mutated (via plugin registration/ordering or plugin destruction) while plugin_hook_call_next iterates over it. The patch introduces a reference count (num_users) and a pending update pointer (new_hooks). hook_start increments the count when a hook is invoked; hook_done decrements it and only swaps in new_hooks when the count reaches zero. Plugin registration now writes to new_hooks and defers the swap if the hook is in use. Destruction removes the instance from both current and pending arrays. A regression test (test_hook_in_use) is added that exercises adding/removing a plugin while htlc_accepted hooks are in flight.
Changed components
lightningd/plugin_hook.clightningd/plugin_hook.htests/test_plugin.pytests/plugins/dep_a.pyInspect captured patch +108 / −19
diff --git a/lightningd/plugin_hook.c b/lightningd/plugin_hook.c
index 48afed6a..34d21075 100644
--- a/lightningd/plugin_hook.c
+++ b/lightningd/plugin_hook.c
@@ -9,7 +9,7 @@
* dispatch an eventual plugin_hook response. */
struct plugin_hook_request {
const char *cmd_id;
- const struct plugin_hook *hook;
+ struct plugin_hook *hook;
void *cb_arg;
/* db_hook doesn't have ld yet */
struct db *db;
@@ -49,18 +49,29 @@ static struct plugin_hook *plugin_hook_by_name(const char *name)
}
/* When we destroy a plugin, we NULL out any hooks it registered */
-static void destroy_hook_instance(struct hook_instance *h,
- struct plugin_hook *hook)
+static void remove_hook_instance(const struct hook_instance *h,
+ struct hook_instance **hookarr)
{
- for (size_t i = 0; i < tal_count(hook->hooks); i++) {
- if (h == hook->hooks[i]) {
- hook->hooks[i] = NULL;
+ for (size_t i = 0; i < tal_count(hookarr); i++) {
+ if (h == hookarr[i]) {
+ hookarr[i] = NULL;
return;
}
}
abort();
}
+static void destroy_hook_instance(struct hook_instance *h,
+ struct plugin_hook *hook)
+{
+ /* NULL it out. */
+ remove_hook_instance(h, hook->hooks);
+
+ /* If there's a pending set of hooks, remove ourselves there too! */
+ if (hook->new_hooks)
+ remove_hook_instance(h, hook->new_hooks);
+}
+
struct plugin_hook *plugin_hook_register(struct plugin *plugin, const char *method)
{
struct hook_instance *h;
@@ -71,8 +82,11 @@ struct plugin_hook *plugin_hook_register(struct plugin *plugin, const char *meth
}
/* Make sure the hook_elements array is initialized. */
- if (hook->hooks == NULL)
+ if (hook->hooks == NULL) {
hook->hooks = notleak(tal_arr(NULL, struct hook_instance *, 0));
+ hook->new_hooks = NULL;
+ hook->num_users = 0;
+ }
/* Ensure we don't register the same plugin multple times. */
for (size_t i=0; i<tal_count(hook->hooks); i++) {
@@ -106,6 +120,31 @@ bool plugin_hook_continue(void *unused, const char *buffer, const jsmntok_t *tok
return resrestok && json_tok_streq(buffer, resrestok, "continue");
}
+static void hook_start(struct plugin_hook *hook)
+{
+ hook->num_users++;
+}
+
+static void hook_done(struct lightningd *ld,
+ struct plugin_hook *hook,
+ void *cb_arg)
+{
+ /* If we're the last one out, we can update hooks */
+ if (--hook->num_users == 0) {
+ if (hook->new_hooks) {
+ log_debug(ld->log, "Updating hooks for %s now usage is done.",
+ hook->name);
+ /* Free this later (after final_cb) if not already done */
+ tal_steal(tmpctx, hook->hooks);
+ hook->hooks = hook->new_hooks;
+ hook->new_hooks = NULL;
+ }
+ }
+
+ hook->final_cb(cb_arg);
+}
+
+
/**
* Callback to be passed to the jsonrpc_request.
*
@@ -171,7 +210,7 @@ static void plugin_hook_call_next(struct plugin_hook_request *ph_req)
do {
ph_req->hook_index++;
if (ph_req->hook_index >= tal_count(hook->hooks)) {
- ph_req->hook->final_cb(ph_req->cb_arg);
+ hook_done(ph_req->ld, ph_req->hook, ph_req->cb_arg);
tal_free(ph_req);
return;
}
@@ -195,10 +234,11 @@ static void plugin_hook_call_next(struct plugin_hook_request *ph_req)
plugin_request_send(plugin, req);
}
-bool plugin_hook_call_(struct lightningd *ld, const struct plugin_hook *hook,
+bool plugin_hook_call_(struct lightningd *ld, struct plugin_hook *hook,
const char *cmd_id TAKES,
tal_t *cb_arg STEALS)
{
+ hook_start(hook);
if (tal_count(hook->hooks)) {
/* If we have a plugin that has registered for this
* hook, serialize and call it */
@@ -222,7 +262,7 @@ bool plugin_hook_call_(struct lightningd *ld, const struct plugin_hook *hook,
* roundtrip to the serializer and deserializer. If we
* were expecting a default response it should have
* been part of the `cb_arg`. */
- hook->final_cb(cb_arg);
+ hook_done(ld, hook, cb_arg);
return true;
}
}
@@ -285,7 +325,7 @@ static void db_hook_response(const char *buffer, const jsmntok_t *toks,
void plugin_hook_db_sync(struct db *db)
{
- const struct plugin_hook *hook = &db_write_hook;
+ struct plugin_hook *hook = &db_write_hook;
struct jsonrpc_request *req;
struct plugin_hook_request *ph_req;
void *ret;
@@ -501,10 +541,17 @@ static struct plugin **plugin_hook_make_ordered(const tal_t *ctx,
return ret;
}
- /* Success! Replace with sorted hooks. */
- tal_free(hook->hooks);
- hook->hooks = notleak(tal_steal(NULL, done));
+ /* If we had previous update pending, this subsumes it */
+ tal_free(hook->new_hooks);
+ hook->new_hooks = notleak(tal_steal(NULL, done));
+ /* If nobody is using it now, we can just replace the hooks array.
+ * Otherwise defer. */
+ if (hook->num_users == 0) {
+ tal_free(hook->hooks);
+ hook->hooks = hook->new_hooks;
+ hook->new_hooks = NULL;
+ }
return NULL;
}
diff --git a/lightningd/plugin_hook.h b/lightningd/plugin_hook.h
index f9543b8d..51ddaf43 100644
--- a/lightningd/plugin_hook.h
+++ b/lightningd/plugin_hook.h
@@ -50,6 +50,13 @@ struct plugin_hook {
/* Which plugins have registered this hook? This is a `tal_arr`
* initialized at creation. */
struct hook_instance **hooks;
+
+ /* Reference count for using the hook right now */
+ size_t num_users;
+
+ /* If someone was using the hooks while we were trying to update,
+ * we put the hook here for later use. */
+ struct hook_instance **new_hooks;
};
AUTODATA_TYPE(hooks, struct plugin_hook);
@@ -60,7 +67,7 @@ AUTODATA_TYPE(hooks, struct plugin_hook);
* still waiting on a plugin response.
*/
bool plugin_hook_call_(struct lightningd *ld,
- const struct plugin_hook *hook,
+ struct plugin_hook *hook,
const char *cmd_id TAKES,
tal_t *cb_arg STEALS);
diff --git a/lightningd/test/run-find_my_abspath.c b/lightningd/test/run-find_my_abspath.c
index b03fcfda..b3c86203 100644
--- a/lightningd/test/run-find_my_abspath.c
+++ b/lightningd/test/run-find_my_abspath.c
@@ -163,7 +163,7 @@ void onchaind_replay_channels(struct lightningd *ld UNNEEDED)
{ fprintf(stderr, "onchaind_replay_channels called!\n"); abort(); }
/* Generated stub for plugin_hook_call_ */
bool plugin_hook_call_(struct lightningd *ld UNNEEDED,
- const struct plugin_hook *hook UNNEEDED,
+ struct plugin_hook *hook UNNEEDED,
const char *cmd_id TAKES UNNEEDED,
tal_t *cb_arg STEALS UNNEEDED)
{ fprintf(stderr, "plugin_hook_call_ called!\n"); abort(); }
diff --git a/lightningd/test/run-invoice-select-inchan.c b/lightningd/test/run-invoice-select-inchan.c
index 64e4168e..5b65c29d 100644
--- a/lightningd/test/run-invoice-select-inchan.c
+++ b/lightningd/test/run-invoice-select-inchan.c
@@ -583,7 +583,7 @@ bool peer_start_openingd(struct peer *peer UNNEEDED,
{ fprintf(stderr, "peer_start_openingd called!\n"); abort(); }
/* Generated stub for plugin_hook_call_ */
bool plugin_hook_call_(struct lightningd *ld UNNEEDED,
- const struct plugin_hook *hook UNNEEDED,
+ struct plugin_hook *hook UNNEEDED,
const char *cmd_id TAKES UNNEEDED,
tal_t *cb_arg STEALS UNNEEDED)
{ fprintf(stderr, "plugin_hook_call_ called!\n"); abort(); }
diff --git a/lightningd/test/run-jsonrpc.c b/lightningd/test/run-jsonrpc.c
index c25cb535..214b2003 100644
--- a/lightningd/test/run-jsonrpc.c
+++ b/lightningd/test/run-jsonrpc.c
@@ -81,7 +81,7 @@ u32 penalty_feerate(struct chain_topology *topo UNNEEDED)
{ fprintf(stderr, "penalty_feerate called!\n"); abort(); }
/* Generated stub for plugin_hook_call_ */
bool plugin_hook_call_(struct lightningd *ld UNNEEDED,
- const struct plugin_hook *hook UNNEEDED,
+ struct plugin_hook *hook UNNEEDED,
const char *cmd_id TAKES UNNEEDED,
tal_t *cb_arg STEALS UNNEEDED)
{ fprintf(stderr, "plugin_hook_call_ called!\n"); abort(); }
diff --git a/tests/plugins/dep_a.py b/tests/plugins/dep_a.py
index be1af29e..10b9c2e7 100755
--- a/tests/plugins/dep_a.py
+++ b/tests/plugins/dep_a.py
@@ -1,5 +1,7 @@
#!/usr/bin/env python3
from pyln.client import Plugin
+import time
+
"""A simple plugin that must come before dep_b.
"""
@@ -8,6 +10,7 @@ plugin = Plugin()
@plugin.hook('htlc_accepted', before=['dep_b.py'])
def on_htlc_accepted(htlc, plugin, **kwargs):
+ time.sleep(1)
print("htlc_accepted called")
return {'result': 'continue'}
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index c6fe4dc2..e2df084a 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -2610,6 +2610,38 @@ def test_htlc_accepted_hook_failonion(node_factory):
l1.rpc.pay(inv)
+def test_hook_in_use(node_factory):
+ """If a hook is in use when we add a plugin to it, we have to defer"""
+ dep_a = os.path.join(os.path.dirname(__file__), 'plugins/dep_a.py')
+ dep_b = os.path.join(os.path.dirname(__file__), 'plugins/dep_b.py')
+
+ l1, l2 = node_factory.line_graph(2, opts=[{}, {'plugin': [dep_a]}])
+
+ NUM_ITERATIONS = 10
+
+ invs = [l2.rpc.invoice(1, f'test_hook_in_use{i}', 'test_hook_in_use') for i in range(NUM_ITERATIONS)]
+
+ route = [{'amount_msat': 1,
+ 'id': l2.info['id'],
+ 'delay': 20,
+ 'channel': l1.get_channel_scid(l2)}]
+
+ for i in range(NUM_ITERATIONS):
+ l1.rpc.sendpay(route,
+ amount_msat=1,
+ payment_hash=invs[i]['payment_hash'],
+ payment_secret=invs[i]['payment_secret'])
+ if i % 2 == 1:
+ l1.rpc.waitsendpay(payment_hash=invs[i - 1]['payment_hash'])
+ l1.rpc.waitsendpay(payment_hash=invs[i]['payment_hash'])
+ else:
+ l2.rpc.plugin_start(plugin=dep_b)
+ l2.rpc.plugin_stop(plugin=dep_b)
+
+ # We should have deferred hook update at least once!
+ l2.daemon.wait_for_log("Updating hooks for htlc_accepted now usage is done.")
+
+
def test_htlc_accepted_hook_fwdto(node_factory):
plugin = os.path.join(os.path.dirname(__file__), 'plugins/htlc_accepted-fwdto.py')
l1, l2, l3 = node_factory.line_graph(3, opts=[{}, {'plugin': plugin}, {}], wait_for_announce=True)
diff --git a/wallet/test/run-wallet.c b/wallet/test/run-wallet.c
index dd5856fa..b434064b 100644
--- a/wallet/test/run-wallet.c
+++ b/wallet/test/run-wallet.c
@@ -604,7 +604,7 @@ bool peer_start_openingd(struct peer *peer UNNEEDED,
{ fprintf(stderr, "peer_start_openingd called!\n"); abort(); }
/* Generated stub for plugin_hook_call_ */
bool plugin_hook_call_(struct lightningd *ld UNNEEDED,
- const struct plugin_hook *hook UNNEEDED,
+ struct plugin_hook *hook UNNEEDED,
const char *cmd_id TAKES UNNEEDED,
tal_t *cb_arg STEALS UNNEEDED)
{ fprintf(stderr, "plugin_hook_call_ called!\n"); abort(); }
Why this scored 61/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.