From 45dfd0f0301af855f068df27b2e40cc9f5713acd Mon Sep 17 00:00:00 2001
From: Peter Hutterer <peter.hutterer@who-t.net>
Date: Mon, 30 Mar 2026 09:41:08 +1000
Subject: [PATCH] lua: separate the API from the metatables

Previously we had one vtable for the libinputplugin and EvdevDevice
objects. This allowed plugins to call __gc(), a decidedly internal
method.

This fixes a use-after-free: A plugin that called EvdevDevice::__gc()
frees the plugin's copy of device->name but leaves the pointer in-place,
a subsequent call will thus cause a UAF read.

Fix this by separating what is the object's metatable from the public
methods that are accessible to a plugin.

CVE-2026-35094

Fixes: #1272

Found-by: Koen Tange <koen@monokles.eu>
Part-of: <https://gitlab.freedesktop.org/libinput/libinput/-/merge_requests/1459>
---
 src/libinput-plugin-lua.c | 54 +++++++++++++++++++++++++++++----------
 test/test-plugins-lua.c   | 33 ++++++++++++++++++++++++
 2 files changed, 73 insertions(+), 14 deletions(-)

diff --git a/src/libinput-plugin-lua.c b/src/libinput-plugin-lua.c
index ac828f7f..c6f99a5f 100644
--- a/src/libinput-plugin-lua.c
+++ b/src/libinput-plugin-lua.c
@@ -562,6 +562,12 @@ libinputplugin_unregister(lua_State *L)
 	return luaL_error(L, "@@unregistering@@");
 }
 
+static int
+readonly_newindex(lua_State *L)
+{
+	return luaL_error(L, "attempt to modify a read-only table");
+}
+
 static int
 libinputplugin_gc(lua_State *L)
 {
@@ -673,7 +679,28 @@ libinputplugin_log_error(lua_State *L)
 	return libinputplugin_log(L, LIBINPUT_LOG_PRIORITY_ERROR);
 }
 
-static const struct luaL_Reg libinputplugin_vtable[] = {
+static void
+setup_vfuncs(lua_State *L,
+	     const char *metatable_name,
+	     const struct luaL_Reg *vfuncs,
+	     const struct luaL_Reg *public_methods)
+{
+	luaL_newmetatable(L, metatable_name);
+	luaL_setfuncs(L, vfuncs, 0);
+
+	lua_newtable(L);
+	luaL_setfuncs(L, public_methods, 0);
+	lua_setfield(L, -2, "__index");
+
+	/* set metatable.__metatable = false to prevent a script from getmetatable(),
+	   which is blocked anyway but safe and sorry and whatnot */
+	lua_pushboolean(L, 0);
+	lua_setfield(L, -2, "__metatable");
+
+	lua_pop(L, 1);
+}
+
+static const struct luaL_Reg libinputplugin_methods[] = {
 	{ "now", libinputplugin_now },
 	{ "version", libinputplugin_version },
 	{ "connect", libinputplugin_connect },
@@ -685,18 +712,18 @@ static const struct luaL_Reg libinputplugin_vtable[] = {
 	{ "log_debug", libinputplugin_log_debug },
 	{ "log_info", libinputplugin_log_info },
 	{ "log_error", libinputplugin_log_error },
-	{ "__gc", libinputplugin_gc },
 	{ NULL, NULL }
 };
 
+static const struct luaL_Reg libinputplugin_meta[] = { { "__gc", libinputplugin_gc },
+						       { "__newindex",
+							 readonly_newindex },
+						       { NULL, NULL } };
+
 static void
 libinputplugin_init(lua_State *L)
 {
-	luaL_newmetatable(L, PLUGIN_METATABLE);
-	lua_pushstring(L, "__index");
-	lua_pushvalue(L, -2); /* push metatable */
-	lua_settable(L, -3);  /* metatable.__index = metatable */
-	luaL_setfuncs(L, libinputplugin_vtable, 0);
+	setup_vfuncs(L, PLUGIN_METATABLE, libinputplugin_meta, libinputplugin_methods);
 }
 
 static int
@@ -1073,7 +1100,7 @@ evdevdevice_gc(lua_State *L)
 	return 0;
 }
 
-static const struct luaL_Reg evdevdevice_vtable[] = {
+static const struct luaL_Reg evdevdevice_methods[] = {
 	{ "info", evdevdevice_info },
 	{ "name", evdevdevice_name },
 	{ "usages", evdevdevice_usages },
@@ -1087,18 +1114,17 @@ static const struct luaL_Reg evdevdevice_vtable[] = {
 	{ "prepend_frame", evdevdevice_prepend_frame },
 	{ "append_frame", evdevdevice_append_frame },
 	{ "disable_feature", evdevdevice_disable_feature },
-	{ "__gc", evdevdevice_gc },
 	{ NULL, NULL }
 };
 
+static const struct luaL_Reg evdevdevice_meta[] = { { "__gc", evdevdevice_gc },
+						    { "__newindex", readonly_newindex },
+						    { NULL, NULL } };
+
 static void
 evdevdevice_init(lua_State *L)
 {
-	luaL_newmetatable(L, EVDEV_DEVICE_METATABLE);
-	lua_pushstring(L, "__index");
-	lua_pushvalue(L, -2); /* push metatable */
-	lua_settable(L, -3);  /* metatable.__index = metatable */
-	luaL_setfuncs(L, evdevdevice_vtable, 0);
+	setup_vfuncs(L, EVDEV_DEVICE_METATABLE, evdevdevice_meta, evdevdevice_methods);
 }
 
 static void
diff --git a/test/test-plugins-lua.c b/test/test-plugins-lua.c
index 4d687203..aba8f6c6 100644
--- a/test/test-plugins-lua.c
+++ b/test/test-plugins-lua.c
@@ -526,6 +526,38 @@ START_TEST(lua_disallowed_functions)
 }
 END_TEST
 
+START_TEST(lua_gc_not_accessible)
+{
+	_destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
+	const char *lua =
+		"libinput:register({1})\n"
+		"assert(libinput.__gc == nil)\n"
+		"function check_device_gc(device)\n"
+		"  assert(device.__gc == nil)\n"
+		"  libinput:log_info(\"gc_not_accessible: ok\")\n"
+		"end\n"
+		"libinput:connect(\"new-evdev-device\", check_device_gc)\n";
+
+	_autofree_ char *path = litest_write_plugin(tmpdir->path, lua);
+	_litest_context_destroy_ struct libinput *li =
+		litest_create_context_with_plugindir(tmpdir->path);
+	if (libinput_log_get_priority(li) > LIBINPUT_LOG_PRIORITY_INFO)
+		libinput_log_set_priority(li, LIBINPUT_LOG_PRIORITY_INFO);
+
+	litest_with_logcapture(li, capture) {
+		libinput_plugin_system_load_plugins(li,
+						    LIBINPUT_PLUGIN_SYSTEM_FLAG_NONE);
+		litest_drain_events(li);
+
+		_destroy_(litest_device) *device = litest_add_device(li, LITEST_MOUSE);
+		litest_drain_events(li);
+
+		litest_assert_logcapture_no_errors(capture);
+		litest_assert_strv_substring(capture->infos, "gc_not_accessible: ok");
+	}
+}
+END_TEST
+
 START_TEST(lua_frame_handler)
 {
 	_destroy_(tmpdir) *tmpdir = tmpdir_create(NULL);
@@ -1219,6 +1251,7 @@ TEST_COLLECTION(lua)
 	litest_add_no_device(lua_register_multiversions);
 	litest_add_no_device(lua_allowed_functions);
 	litest_add_no_device(lua_disallowed_functions);
+	litest_add_no_device(lua_gc_not_accessible);
 
 	litest_add_no_device(lua_frame_handler);
 	litest_add_no_device(lua_device_info);
-- 
GitLab

