badmalloc (CVE-2023-32428) - a macOS LPE

Posted on 2024-11-24 in blog

I recently realised that I still owe you guys some writeups, so since OBTSv7 is around the corner here's the one for badmalloc. I found this back in March 2023, and it got fixed in October.

About the bug

There's a bug in MallocStackLogging, Apple's "magical" framework that allows developers to debug application memory allocations. It has been a part of macOS for about 20 years (if not longer). I don't know exactly, since the earliest reference I could find was in Phrack 63 - in nemo's "OSX heap exploitation techniques".

The reason it's so "magical" is because the MallocStackLogging.framework will be loaded by the dynamic loader (dyld) into any process whenever a MallocStack* environment variable is detected. No privileges are necessary to do this, and yes, it works with entitled and suid root binaries :)

So what?

Well, it seemed peculiar to me that this magic toggle exists and not only that, but the library is force-loaded by dyld itself. It seemed like a very foolish thing to do, and as we will see it was.

To start at the beginning, these strange MallocStackLogging environment variables stood out to me when I saw them, and if I remember correctly I read somewhere that there's a very special one, called MallocStackLoggingDirectory, and indeed I could confirm this both with Ghidra and using the source.

Now this is pretty much what you think, you provide it to the application and the force-loaded framework will log things, by writing them into a new file in the specified directory.

This seemed to be pretty dangerous, for two reasons:

1) the target process has no idea that this is happening

2) the file will be written with the privileges of the target

To their credit, developers at Apple saw this coming, so they came up with some rather naive mitigations:

  • the destination directory is checked with the access() syscall first, and if that returns -1, no operation will be done

  • open() will be used to create a file

    • but it won't overwrite files (O_CREAT | O_EXCL)

    • won't follow symlinks (O_NOFOLLOW)

    • permission bits are correctly set to 0o700 (rwx------), so we can't play tricks with umask

  • the filename is randomised: stack-logs.60418.100b34000.id.iXW2gZ.index

These seem like some thoughtful mitigations, but pause for a second and try to see if there's a problem with them. DM me if you got it right :)

Even if we could bypass these mitigations, there's still the problem of content control. The heap allocations are logged in a pretty peculiar format, so controlling the contents of the file is not really an option.

This, on the face of it seems like a total garbage bug.

Mitigating the mitigations

Right from the start there are some problems with the mitigations:

1) access() is useless for securing filesystem operations, this is common knowledge (you can switch the file after access() but before open())

2) open()'s O_NOFOLLOW flag will only NOT follow the LAST path component. This one is in the man page, but is not as common knowledge as I'd like it to be.

3) there's a third problem here that I will reveal later, as I didn't realise it at the time

By replacing a non-last path component with a symlink in a race, we can have this file written to anywhere where the running application has a right to write to. Pretty cool.

For example with a path like /tmp/target/Users/test, we can swap the target directory rapidly with a symlink to / and the file would be written to the "test" user's home.

Now this is pretty good, however it's still far from being useful since we:

  • don't control the filename

  • don't control the contents

Now, I spent a lot of hours trying to figure out how to smuggle payload into the output, but it's not an easy data structure to manipulate. Especially since all of this shenanigans would have to be induced in the target application's heap allocations. Doing this externally in another application is pretty unlikely.

Needless to say, if you find a way to do this please let me know (after reporting it).

Predicting the random filename

Predicting the random filename was not necessary in the end, but I found some hilarious bugs in the code.

Feel free to skip this if you are pressed for time.

I only included it for learning purposes on bypassing randomness.

Firstly, let's run "id" a couple times to see what we are up against, the pattern is easy to see:

stack-logs.18410.10245c000.id.jjv0XG.index
stack-logs.18411.10514c000.id.kfWnGs.index
stack-logs.18412.100be0000.id.8St03t.index
stack-logs.18413.104200000.id.9VrNfm.index
stack-logs.18414.102900000.id.CwxjRi.index
stack-logs.18415.100520000.id.YoIoPW.index
^A         ^B    ^C        ^D ^E     ^F

A is fixed, B is the pid, D is the program's name and F is fixed.

We know all of these values, so only C and E is missing.

Attacking C

From the small sample it's obvious that C is not very random. Digging in the source I found that C actually is...

drumroll please

The ASLR offset!

This doesn't matter much on macOS where you can easily get the slide already, but it works on iOS so I thought I'd mention it.

Attacking E

6 characters of ASCII letters + digits give us about 57 billion combinations, however it's not actually this bad. Why?

Because macOS by default uses HFS+ or APFS, and these are case-insensitive by default, cutting our number of combinations down to about 2 billion.

This is still infeasible to guess though (if we wanted to), so after some digging I noticed something pretty weird. The random generator sometimes would generate a truncated filename. I have no idea why - and it does not matter - but likely the random generated string will contain an offending byte like a \0. This results in the truncation of the rest of the string:

/tmp/stack-logs.77512.104b6c000.id.

This basically gives us a 1/256 chance to not have to care about E at all, but upon investigating further I realised that the return value of strncat() goes unchecked when the parts are stitched together.

This means that if I provide a long enough directory name, the filename will get truncated:

$ MallocStackLoggingDirectory="/tmp/$(python3 -c "print('/'*1017)")" MallocStackLogging=1 id

...

id(77705) MallocStackLogging: stack logs being written to /tmp////[TRUNCATED]///s

...

This bug turns the entire filename randomisation useless.

I ended up not needing to mess with the filename (as you'll see soon), but I thought this was useful information for anyone struggling with other randomness-based scenarios.

As a side-note here: string handling bugs like this (particularly truncation) are fairly common when paths are constructed, and in the context of file operations they can make or break the exploitability of a bug, so pay attention to them.

Garbage to gold?

Right now I can use the bug to do a couple interesting things:

1) I can write a file called "s" in any directory as root, with sort of random contents.

I could bet on the fact that eventually lady luck will favor me so much as to put a useful string into this file, however I can't read these files as a user so I won't know when this happens. Furthermore, the file's content looks like this:

00000000  00 40 00 00 00 00 00 00  55 95 fe 02 01 00 00 00  |.@......U.......|
00000010  01 00 00 00 00 88 87 00  10 00 00 00 00 00 00 00  |................|
00000020  00 40 00 00 00 00 00 00  55 95 fe 02 01 00 00 00  |.@......U.......|
00000030  01 00 00 00 00 a0 ac 02  20 00 00 00 00 00 00 00  |........ .......|
00000040  00 00 10 00 00 00 00 00  55 95 cb 03 01 00 00 00  |........U.......|
00000050  a9 00 00 00 00 88 e4 03  10 00 00 0a 00 00 00 00  |................|
00000060  00 00 10 00 00 00 00 00  55 95 cb 03 01 00 00 00  |........U.......|
00000070  41 01 00 00 00 28 15 01  20 00 00 00 00 00 00 00  |A....(.. .......|
00000080  a0 00 00 00 00 00 00 00  d5 12 be 03 00 60 00 00  |.............`..|
00000090  41 01 00 00 00 a0 52 01  02 00 00 00 00 00 00 00  |A.....R.........|
000000a0  00 08 00 00 00 00 00 00  55 f5 00 42 01 00 00 00  |........U..B....|
000000b0  41 01 00 00 00 98 46 02  02 00 00 00 00 00 00 00  |A.....F.........|
000000c0  00 20 00 00 00 00 00 00  55 fd 00 42 01 00 00 00  |. ......U..B....|
...

I tried controlling any portion of this file by allocating specific patterns, but neither the address, the count nor the allocation size showed up. And even if it would, good luck reproducing the same allocation patterns in an external, privileged executable.

2) I can write a file with a partially controlled malicious filename

Fun fact: you can use $'hi\n\x41' in bash/zsh to easily create payloads that have shell escape sequences, new lines and other malicious characters in them.

These are also legal filenames on most OS-es, leading to things like this:

$ ln -sf /bin/ls $'hi\nthere\n'
$ MallocStackLogging=1 ./hi
there
(67902) MallocStackLogging: could not tag MSL-related memory as no_footprint, so those pages will be included in process footprint - (null)
hi
there

This produces an executable filename with a newline (\n) in it, but it might as well be any dangerous character, like a backtick. Since the executable's name makes it into the written file's name, we can use the bug to place a file as root with this malicious name.

This by itself is not useful. However if there's some other program (usually a script) that processes filenames insecurely, this can quickly turn into command execution.

This is a fairly common pattern on Linux with scripts running as root. Since the directories these scripts operate on are writable only by root, the assumption is that these filenames are trusted.

Unfortunately for me, I haven't found anything like that on macOS. Usually scripts do this sort of thing, and macOS barely has any. periodic did not have this bug.

Taking a break

After banging my head into this wall for an extended period of time I switched to other bugs, and this ended up in the metaphorical drawer. It was a good 6 months until a solution dawned on me when I was exploiting a similar vulnerability.

This is what I missed:

The open() does not have O_CLOEXEC!

This means, that if there is a privileged application that I can run and it executes something that I also control, I can have the file descriptor leaked back to me. It's likely that such a program exists, as this "feature" affects every suid-root executable on the system.

Then the question is:

Is there a suid-root binary that executes a user-controlled application (preferably by design)?

Yes, yes there is. Our old friend: crontab

crontab -e will execute our executable we provide via $EDITOR, by design.

crontab is not at fault here, since it can not expect the OS to behave this way. As far as it's concerned it has left the file descriptor table in a correct state, which would be true if it hadn't been for a force-loaded library opening files insecurely.

The exploit

To exploit this vulnerability all we have to do is:

  • set $EDITOR to our script

  • execute MallocStackLogging=1 MallocStackLoggingDirectory=$PWD/dir1 crontab -e

  • in another process, race the file open by switching dir1 with a symlink poiting to /etc/sudoers.d

  • in our script we detect if we were successful (we have an open file under /etc/sudoers.d)

    • we wait a bit

    • truncate the file

    • write ALL ALL=(ALL) NOPASSWD:ALL to it

  • we sudo to root without a password :)

Demo and code

Demo video

Here's the exploit code: https://github.com/gergelykalman/CVE-2023-32428-a-macOS-LPE-via-MallocStackLogging

The fix

The fix was deployed by Apple to the latest beta in ~3 weeks, which was great to see!

The following improvements were made:

  • open() is now correctly using O_NOFOLLOW_ANY, to prevent any symlinks in the path
    • and not only that, the code calls realpath() to normalize the path, so playing with ../../../ is not an option
  • open() will also use O_CLOEXEC, so the file descriptor will no longer inherit if the target application executes something
  • the bugs in the random generator are fixed
  • the bugs with the file truncation are fixed

Apple did a great job with the mitigations, although I still think it's not enough. This feature should not exist, because I can still pull the old-school stderr closing trick with it.

The stderr closing trick:

What if I have a privileged application that I can invoke with MallocStackLogging in such a way that the log file gets opened at fd #2 (stderr)? Then any error message written by the application or even MallocStackLogging itself will end up being written to this file.

For example:

$ id hello
id: hello: no such user
$ MallocStackLogging=1 MallocStackLoggingDontDeleteStackLogFile=1 MallocStackLoggingDirectory=$PWD id hello 1>&- 2>&-
$ cat stack-logs.63898.1049ec000.id.Dt6LRc.index 
[REMOVED FOR BREVITY]\^UQɁ`ɁUQɁ UQɁLUQɁUQɁid: hello: no such user

I can't do the same trick with suids, as the kernel protects them from this exact attack, however it's still dangerous because the kernel does not protect entitled applications the same way.

Exploitation would still be unlikely as the filename is still not controlled, but I'd rather have this behind some entitlement to make sure it's useless for privilege escalation.

Does this work on iOS?

I investigated this on iOS, but I concluded (perhaps falsely) that this even has less chance there:

  • there are no suid binaries on iOS

  • there is no sudo on iOS

It might be possible to exploit this with an entitled target, by using the stderr closing trick, however it's rather unlikely that I could find good targets for this. Mostly due to the fact that the filename is not controlled.

Also I'd need unsandboxed code execution on iOS to be able to run privileged targets and even then AMFI would prevent me from running the best ones.

I did not want to spend more time on this bug considering these hefty limitations, so I handed in the report as is.

Conclusion of the bug

What I learned from all of this is to be more deliberate and methodical. I started chasing down the randomisation aspect for pretty much no reason while entirely missing the fact that O_CLOEXEC was missing.

Had I realised it earlier I would have been done with very little effort, but hey, this is just how vulnerability research works sometimes. Evidently I found something that nobody else did in 20 years, so I can't be too hard on myself.

The bounty

For all of this work, I received $22500 which was pretty disappointing, since my TCC bypasses were worth more than this ($30500).

I thought that since fixes were deployed for all OSes they produce, Apple might award me with a higher payout, but I was told in no uncertain terms that only demonstrated impact matters.

The lesson is that Apple will only pay for the impact and platforms for which you could provide a weaponised exploit for. It doesn't matter how many platforms have the bug and how many variants they will find internally.

With this in mind:

Only ever send bugs to Apple if you can demonstrate the maximum impact.

It's better to sit on a 0day until you figure it out, or even to share the bounty and collaborate with someone to get as big of an impact as possible.

It's sad that this is what we have to do, but these are the rules Apple made.

Apple's communication

This time around the communication process was particularly unpleasant.

To sum up:

  • they fixed the bug and shipped the fix in 3 weeks, but sat on the bounty for more than 7 months
  • I was not kept in the loop at all, I had to discover the fix myself and keep asking for updates. This is not a huge deal, but worth mentioning.
  • Apple did not credit me correctly for ~6 months
  • Apple repeatedly said the adjudication will happen in two weeks, but somehow it took them 5 months. They never told me anything.

And the worst interaction occurred after I finally got the credit published and the bounty awarded. When I disputed the amount (thinking the fixes for iOS count) I received the following.

I was told that they reviewed the ticket and thus realised that the bug only affects a specific macOS version. Therefore they will start removing all of my credits from everything else for this CVE.

This was absolutely infuriating. Not only has Apple held my research hostage for 7 months at this point (6 months without credit), now they suddenly "reviewed" the ticket and it just so happens that I got credited accidentally.

I obviously pushed back on this (as they were clearly and spectacularly wrong), and guess what? Nothing happened. Not only nothing got removed, I had the credits fixed and one extra added for Monterey.

I don't know whether this was some jaded employee's brilliant idea of an idle threat or just an incredible flash of incompetence, but this interation really left me with a bad taste in my mouth.

How can I expect to work with a company that repeatedly does this to researchers? How does it make sense to have a bounty program for white-hat researchers, keep them in the dark, repeatedly lie to them, underpay them and still expect them to keep coming back?

This has been a year ago and it still pissed me off. I had to calm down and think about it, because it's not usually like this. Clearly they were having problems sorting this out across all platforms and versions, so it's likely that coordinating the fix internally was particularly difficult. I get that.

Now I'm not excusing this behaviour, but I won't put them on blast here for what happened, like I originally planned to. But I won't be silent about it either. I wanted to include this section for the sake of anyone else who might run into a similar situation: It sucked, it was unpleasant but fight back and if you're right eventually they'll fix it.

All in all I'll write this off as a nasty case of a long overdue PTO. We've all felt burned out and frustrated and sent the email that we shouldn't have.

Shit happens.

Timeline

  • 2023.03.08: Report sent to Apple
  • 2023.03.28: Told Apple that this will be presented on OBTSv6 (2023-10-09)
  • 2023.04.26: I notice the fix in the beta
  • 2023.04.26: Apple asks me to retest
  • 2023.05.10: Apple asks me for follow-up on the retest
  • 2023.05.10: Told Apple the fix is solid, I could not bypass it
  • 2023.05.10: Apple tells me they will adjudicate the bounty in the next two weeks
  • 2023.07.07: I ask Apple for an update regarding the bounty, and ask why no credit was published?
  • 2023.07.08: Apple tells me they'll add my credit and the bug will be adjudicated in the next two weeks
  • 2023.08.10: I ask Apple for another update, as no bounty was given and no credit was updated.
  • 2023.08.14: Apple says they'll fix the credit, apologies for the delay, bounty will be decided in September
  • 2023.09.05: Apple finally adds my credit to the changelogs. I don't get notified of this
  • 2023.10.02: I ask Apple if talking about this on OBTS would make it ineligible for a bounty
  • 2023.10.02: Apple confirms that it's fixed and that it's clear to go on OBTS.
  • 2023.10.23: Bounty finally awarded