Nightjar

Out-of-bounds write in libcue from a negative INDEX number

Identifier
CVE-2023-43641
Software
libcue
Affected
libcue 2.2.1 and earlier
Fixed in
2.3.0
Reported by
Kevin Backhouse, GitHub Security Lab
Disclosed
19 September 2023

Somebody who rips an album to one big FLAC file still needs somewhere to record that track two starts at 3:35. That is what a cue sheet is for. It is a plain text file, left over from the days of burning your own CDs, and it says where the tracks are. Kevin Backhouse uses this one, and it is entirely ordinary:

text
REM GENRE "Pop, dance pop"
REM DATE 1987
PERFORMER "Rick Astley"
TITLE "Whenever You Need Somebody"
FILE "Whenever You Need Somebody.mp3" MP3
  TRACK 01 AUDIO
    TITLE "Never Gonna Give You Up"
    PERFORMER "Rick Astley"
    INDEX 01 00:00:00
  TRACK 02 AUDIO
    TITLE "Whenever You Need Somebody"
    PERFORMER "Rick Astley"
    INDEX 01 03:35:00

The INDEX lines are the interesting ones. An index is a position marker inside a track, numbered 00 to 99. That range comes from the Red Book audio CD standard. Index 01 is the start of the audio. Index 00, if present, is the start of the pregap.

Change one of those lines to INDEX 4294567296 0 and you have a memory corruption bug. It works on any machine running GNOME with an unpatched libcue. Backhouse turned it into a working one-click remote code execution. He reported it to the maintainer on 19 September 2023. It was disclosed on 9 October as GHSL-2023-197, CVE-2023-43641.

one number, five steps INDEX 4294567296 0 flex rule in cue_scanner.l atoi(yytext) no way to report failure i = -400000 400000 less than 2^32 passed to track_set_index if (i > MAXINDEX) -400000 is not > 99 the check passes track->index[-400000] = ind
One INDEX line becomes a negative array subscript, and nothing between the scanner and the store rejects it.

Root cause#

libcue is a tiny library: a bison grammar for cue sheets and a few structs to hold what comes out. The number 4294567296 first passes through the flex scanner. Here is the whole rule that handles it, from cue_scanner.l line 132:

c
[[:digit:]]+    { yylval.ival = atoi(yytext); return NUMBER; }

atoi has no way to report failure. Hand it something larger than an int can hold and you get whatever you get. 4294567296 is 400000 less than 2^32, so it comes back as -400000.

That number then travels through the parser and arrives here, in cd.c:

c
void track_set_index(Track *track, int i, long ind)
{
	if (i > MAXINDEX) {
		fprintf(stderr, "too many indexes\n");
		return;
	}
	track->index[i] = ind;
}

The bounds check is half a bounds check. MAXINDEX is 99. The field it guards is declared long index[MAXINDEX+1] inside struct Track. So the check is right about the top of the array and has nothing to say about the bottom. i is a signed int. Pass it -400000 and the store lands 3.2 megabytes before the start of the array.

struct Track in memory index[-400000] 3.2 MB of other heap memory index[0] .. index[99] nothing rejects i < 0 i > MAXINDEX rejects i = -400000 moves the store this far left higher addresses to the right stored value: the second field of the INDEX line
The check guards the top of index[100]. Nothing guards the bottom.

Both halves of that write are yours. The offset comes from the index number. Any integer works, as long as atoi folds it into something negative. The value is the second field on the INDEX line. It is converted to frames and stored as a long. Backhouse's summary is one sentence: "Since the value of ind is also attacker-controlled, this is a very powerful vulnerability."

The advisory's example file is three lines. The full exploit PoC was held back to give people time to patch.

text
FILE pwned.mp3 MP3
TRACK 000 AUDIO
INDEX 4294567296 0

The patch is one line.

text
-       if (i > MAXINDEX) {
+       if (i < 0 || i > MAXINDEX) {

Note what didn't get fixed, and didn't need to be. atoi is still there. A scanner that returns nonsense for out-of-range input is not, by itself, a vulnerability. The sink that trusts the number is.

That fix shipped as libcue 2.3.0 on 10 October 2023. The previous release, 2.2.1, is from May 2018.

Impact#

libcue is a dependency of tracker-miners, and tracker-miners is part of GNOME. That is why this got a coordinated disclosure through the distros list rather than a quiet commit. Backhouse opens his write-up by pointing at xkcd 2347. It is the right reference.

tracker-miners is the thing that makes the GNOME search bar work. It indexes your files so that typing a few letters finds them. It runs as two processes. tracker-miner-fs is always running and watches certain directories with inotify. tracker-extract is started on demand to read a new file, and it exits again a few seconds later. The watched directories include ~/Downloads.

So: you click a link and a .cue file lands in your downloads folder. inotify fires. tracker-miner-fs starts tracker-extract. tracker-extract picks a parser based on the filename extension, and .cue means libcue. No double-click, no dialog, no "open with". Backhouse's video is a browser, a click, and a calculator.

from click to parser, with nothing opened by hand you click a link download ~/Downloads/x.cue inotify tracker-miner-fs starts it on demand tracker-extract extension .cue libcue parses the file no double-click, no dialog, no "open with"
A downloaded .cue file reaches the libcue parser without the user opening anything.

The two-process design helps the exploit rather than hindering it. A process that was just started has a far more predictable heap than one that has been running for six hours. tracker-extract also spawns a fresh thread to scan the file, and that thread gets its own malloc arena. Backhouse found the layout in that arena identical on every single run for a given distribution. Different between Ubuntu 23.04 and Fedora 38, identical within each.

And if it ever did fail, tracker-extract restarts on demand. An attacker gets as many attempts as he has files.

an attacker could potentially load a zip file with thousands of copies of their exploit to increase their chance of success when the victim unzips the download.

Both processes run as you, not as root. So this is code execution as the logged-in user, and going further would need a second bug. That's still your ssh keys and your browser profile.

The best part of the write-up is a footnote to the exploitation work. tracker-extract runs inside a seccomp sandbox. Backhouse didn't know that until his nearly-finished proof of concept died with Disallowed syscall "close_range" caught in sandbox. He assumed he'd taken a code path that happened to use a blocked syscall. He went looking for a different route, found one, and moved on. The GNOME developers then asked him how he'd escaped the sandbox. That is when he realised he had.

It turned out that I'd discovered the escape entirely by accident: while I was working on the new route, I unwittingly made a change to the PoC that solved it. I have since discovered that I could have got the original PoC working with a one-line change.

Carlos Garnacho hardened the sandbox afterwards.

Sources

  1. 1GHSL-2023-197: out-of-bounds array access in libcue, CVE-2023-43641securitylab.github.com
  2. 2Coordinated Disclosure: 1-Click RCE on GNOME (CVE-2023-43641)github.blog