A bug hiding in plain sight

During preparations for a Black Hat EU 2021 presentation, a demo that was supposed to crash Ubuntu's accountsservice for one reason kept crashing for another. Further investigation revealed the unexpected crash was caused by a separate vulnerability — one that existed in fully patched releases. That bug, now tracked as CVE-2021-3939, turned out to be an incorrect call to user_get_fallback_value:

static gchar *
user_get_fallback_value (User *user,
                         const gchar *property)
{
        static gchar *system_language;
        static gchar *system_formats_locale; <===== ONLY ALLOCATED ONCE

        if (g_strcmp0 (property, "Language") == 0 && system_language)
                return system_language;
        if (g_strcmp0 (property, "FormatsLocale") == 0 && system_formats_locale)
                return system_formats_locale; <===== RETURNED TO CALLER
        ...

The function relies on a static variable, system_formats_locale, which is allocated on first use and reused on every subsequent call. Callers are not supposed to free that pointer. But in user_change_language_authorized_cb, exactly that happens:

if (!is_in_pam_environment (user, "FormatsLocale")) {

        /* set the user formats (certain LC_* variables) explicitly
           in order to prevent surprises when LANG is changed */
        g_autofree gchar *fallback_locale = user_get_fallback_value (user, "FormatsLocale"); <===== NO ALLOC
        g_autofree gchar *validated_locale = user_locale_validate (user, fallback_locale, context);
        gchar *formats_locale = user_update_environment (user,
                                                         validated_locale,
                                                         "save-to-pam-env",
                                                         context);

        if (formats_locale != NULL)
                accounts_user_set_formats_locale (ACCOUNTS_USER (user), formats_locale);
        <===== fallback_locale AUTOMATICALLY FREED HERE
}

The g_autofree annotation on fallback_locale means the memory is automatically released when the function exits, leaving the static variable pointing at freed memory. In normal operation the bug doesn't fire because a value for FormatsLocale is found in the user's ~/.pam_environment file. An unprivileged user can force the vulnerable path easily:

rm -f ~/.pam_environment
dbus-send --system --print-reply --dest=org.freedesktop.Accounts /org/freedesktop/Accounts/User1001 org.freedesktop.Accounts.User.SetLanguage string:hi

Repeating those steps a few times crashes accountsservice with a double-free error.

Turning a double-free into useful corruption

The common route is to convert the double-free into a use-after-free:

converting a double-free to a use-after-free

  • A chunk is allocated and stored in system_formats_locale.
  • Triggering the bug frees that chunk, leaving a dangling pointer.
  • Another code path allocates memory and receives the same chunk, so two owners both believe they control it.
  • Trigger the bug a second time to free the chunk again.
  • A second allocation from another code path yields a third owner of the same chunk.

With three owners of one chunk, an overwrite by one owner can drive another owner into doing something it shouldn't.

Initial assessment was pessimistic — perhaps under 25% chances of success. The core problem is that the bug only affects a single small chunk of size 0x20:

0x20-sized chunk, marooned amongst long-lived chunks

The chunk is allocated early in the process lifetime, at a fixed address that can't be influenced. It sits among long-lived chunks, so it can't be resized through consolidation with adjacent free memory. Exploitation of memory corruption bugs is further complicated by mitigations like ASLR, which usually requires an infoleak to bypass. Such a leak did surface when testing. After triggering the bug, the contents of the chunk could be read through user_new:

accounts_user_set_formats_locale (ACCOUNTS_USER (user), user_get_fallback_value (user, "FormatsLocale"));

user_new runs immediately for human user accounts the process knows about at startup. System accounts are only loaded on demand — the root user, for instance, can be pulled into memory by sending:

dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply /org/freedesktop/Accounts org.freedesktop.Accounts.FindUserById int64:0

The current chunk contents get cached, making them readable:

dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply /org/freedesktop/Accounts/User0 org.freedesktop.DBus.Properties.Get string:"org.freedesktop.Accounts.User" string:"FormatsLocale"

Addresses leak only when the bytes happen to form a valid UTF-8 string, so the leak worked only sometimes. Even knowing the ASLR offsets, the overwrite capability is constrained to a single 0x20 chunk, and the UTF-8 filter further narrows what pointers could be written back. The infoleak was not going to be the key to exploitation.

The leak did reveal something useful about the bug's behavior. After a trigger, the chunk frequently held strings like "Session" or "Icon" — artifacts from user_save_to_keyfile, which gets called indirectly right after the bug fires. That function can't be avoided, and the chunk is of limited use when caught there.

Attempts at heap grooming also went nowhere. The goal was to control which subsequent allocation would land on the vulnerable chunk, but the layout stayed effectively random. Even a separate memory leak in accountsservice, which might have depleted allocator caches and made allocations more predictable, made no observable difference.

Letting randomness work for you

Randomness is frequently used as a defensive measure — ASLR being the standard example — but it's often weaker than intended. After failing to tame the allocator's chaos, the approach flipped: embrace it, and even amplify it. Since each call to user_save_to_keyfile frees and reallocates keyfile data while jumbling memory, an unprivileged user can shuffle the heap by changing their own email address:

dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply /org/freedesktop/Accounts/User1001 org.freedesktop.Accounts.User.SetEmail string:'[email protected]'

The exploit makes a random number of SetEmail calls, hoping to shake the vulnerable chunk loose and make it available to a more interesting allocation target. This won't succeed every time, but that's not a problem — the double-free lets the exploit crash and restart accountsservice as often as needed. Systemd rate-limits restarts to five per ten seconds, which slows things down but doesn't stop them.

Picking something worth corrupting

A successful exploit needs a 0x20-sized allocation whose overwrite leads to privilege escalation. The obvious appealing target — changing one's own username to "root" — isn't feasible because accounts classified as human users are loaded at process startup, so interference in their allocation isn't possible. System accounts load on demand, but unprivileged users can only call D-Bus methods on their own account, limiting what can be reached.

One speculative avenue involved D-Bus unique bus names. Processes connecting to the bus get names like :1.3591, and those names are used for credential checks — forging one might allow impersonating a privileged process. Overwriting a bus name through the double-free seemed conceivable, but the bus names are allocated on a different thread than the one containing the vulnerable code. Each thread has its own malloc arena, and moving the vulnerable chunk between arenas would require an allocation in one thread and a free in another. Possible in theory, but far too hard to control reliably to make it a viable strategy.

A flawed plan that worked anyway

My initial strategy centered on the CheckAuthData struct allocated by accountsservice in daemon_local_check_auth. The flow works like this: when a user asks accountsservice for something (e.g., changing their email), accountsservice sends an asynchronous D-Bus request to polkit for authorization. Some requests are approved automatically by polkit; others require an admin. The CheckAuthData struct stores the callback that runs once the polkit check completes. Since that D-Bus call is asynchronous, there is a window in which I could trigger the double-free bug after accountsservice sends its request but before the reply arrives.

My intended sequence was:

  1. Change my own email (a request polkit approves instantly).
  2. Trigger the double-free bug.
  3. Attempt to change the root user's password (which should require admin rights).
  4. Hope that both CheckAuthData allocations from steps 1 and 3 share the same heap chunk.
  5. Get root's password changed when the email-change callback fires, because the original CheckAuthData was overwritten by the password-change one.

The whole plan hinged on a single size constraint: accountsservice's CheckAuthData is a 0x40-sized allocation, but the vulnerable chunk is only 0x20. The mismatch means this approach could not work directly.

I won't dwell on the increasingly complex workarounds I attempted to bridge that size gap—suffice it to say they were elaborate and wrong. Then something odd happened. After leaving a new iteration of the exploit running while I stepped away in frustration, I realized hours later that the scheme had a fundamental flaw and could not possibly succeed. But when I returned to the machine, the exploit had triggered. I was certain the design was wrong, so it must have succeeded through some mechanism I hadn't anticipated.

Trailing the real mechanism

Figuring out what actually happened proved difficult. The exploit typically runs for hours, requiring thousands of accountsservice restarts, which rules out simply attaching a debugger and stepping through. The rr debugging tool crashes on accountsservice, and adding debug output with printf altered the timing and broke the exploit. Worse, I was logging the wrong data entirely.

The breakthrough came from inserting a long sleep inside user_change_password_authorized_cb, a function that only runs on a successful exploit. That sleep gave me enough time to attach gdb and inspect the call stack before the process continued. The backtrace still left me guessing about earlier events, but it pointed me in the right direction:

#3  0x0000564cfbadff72 in user_change_password_authorized_cb at ../src/user.c:1920
#4  0x0000564cfbad5f75 in check_auth_cb at ../src/daemon.c:1427
#5  0x00007f2620ed2fe2 in g_simple_async_result_complete at ../../../gio/gsimpleasyncresult.c:802
#6  0x00007f2620c8bc8b in check_authorization_cb at /home/kev/projects/polkit/policykit-1-0.105/src/polkit/polkitauthority.c:835

Frame #6 lies in polkit's client-side library, and the irony is stark: polkit has its own struct named CheckAuthData that performs the same job as the one in accountsservice. Critically, polkit's version is small enough to fit into an 0x20-sized chunk. The exploit had been working all along almost exactly as planned—except it was corrupting polkit's CheckAuthData, not accountsservice's.

Streamlining the attack

With that knowledge, I could simplify the exploit considerably. A rough outline:

  1. Fork into two processes.
  2. One process triggers the double-free bug roughly once per second.
  3. The second process fires rapid bursts of alternating SetEmail and SetPassword messages.

The two-process design compensates for the uncertain timing. I need a SetEmail request to occupy the vulnerable chunk just before the bug fires, then a SetPassword request to overwrite that email request's CheckAuthData just after the corruption. The bug itself is triggered inside a polkit callback, making the timing dependent on polkit's internal behavior, which is hard to predict. Rather than chase precise timing, I chose to rely on many attempts and non-determinism.

All three versions of the exploit are published in the Security Lab repository on GitHub. The first is the original proof-of-concept sent to the Ubuntu security team; it occasionally hangs waiting for a D-Bus reply that never arrives. The second improves reliability with epoll to avoid those hangs. The third is the simplified version described here.

Why pure logic beats memory management tricks

This exploit stands out because it abuses a memory management bug through application logic alone, sidestepping classic Malloc Maleficarum attacks that corrupt heap metadata. Modern glibc malloc mitigations increasingly defeat those traditional techniques, yet none of them stop this style of attack. Instead, the exploit steers the target process into an unintended but valid control-flow path—there is no control-flow hijack, only a logical hijack of which authorization callback fires.

That makes even the smallest heap bug a potential foothold for someone with enough persistence. This is also, by far, the most inelegant exploit I have written; it relies on luck and the ability to crash accountsservice continuously until the pieces align. In practice, an attacker will happily wait a few hours for a root shell. Considering the depth of today's memory protections, it feels almost magical that a bug this small can still be leveraged so completely. Sometimes getting root is just a matter of a little wishful thinking.