- Security researcher
- Focus on macOS security: userland and kernel
- Twitter @0x3C3E
Kernel Memory Disclosure, my 🥇 and 🥈 bugs in XNU
Apple interviewer asked me several times why I don't look for bugs in the kernel
Is it hard for you?
- Before
December 2022
, I haven't looked into theXNU
source code
- Search online and tag writeups
- Prepare a debugging environment
- Use CodeQL to search for some patterns
- A tale of a simple Apple kernel bug
- Weggli was used to find a specific pattern
- Finding a memory exposure vulnerability with CodeQL
- CodeQL was used, the author found a bug in the DTrace module of XNU
- Released in
2005
by Oracle - Apple merged it into XNU in
2007
- Was it thoroughly audited?
- It's complex and has its emulator in
the kernel
#define DIF_OP_OR 1 /* or r1, r2, rd */
#define DIF_OP_XOR 2 /* xor r1, r2, rd */
...
#define DIF_OP_STRIP 80 /* strip r1, key, rd */
- Framework for doing static analysis
- Models code as data → database
- Write logic-based SQL-like queries to find patterns
- Have to compile the program we want to query
- By default, some files were missing
- A great script to build a CodeQL database for XNU by pwn0rz
I decided to look for OOB issues. For that, I wrote a query to find such code, which meets the conditions below:
a >= b
, wherea
is signed, andb
is not- No
a <= 0
anda < 0
checks a
is an array index
from Variable arg
where exists(
GEExpr ge | ge.getLeftOperand() = arg.getAnAccess()
and ge.getLeftOperand().
getExplicitlyConverted().
getUnderlyingType().(IntegralType).isSigned()
and ge.getRightOperand().
getExplicitlyConverted().
getUnderlyingType().(IntegralType).isUnsigned()
)
select arg
from Variable arg
where not exists(
LTExpr le | le.getLeftOperand() = arg.getAnAccess()
and le.getRightOperand().getValue() = "0"
)
and not exists(
LEExpr le | le.getLeftOperand() = arg.getAnAccess()
and le.getRightOperand().getValue() = "0"
)
select arg
from Variable arg, ArrayExpr ae
where ae.getArrayOffset() = arg.getAnAccess()
select ae.getArrayOffset(),
ae.getEnclosingFunction()
from Variable arg, ArrayExpr ae
where exists(
GEExpr ge | ge.getLeftOperand() = arg.getAnAccess()
and ge.getLeftOperand().
getExplicitlyConverted().
getUnderlyingType().(IntegralType).isSigned()
and ge.getRightOperand().
getExplicitlyConverted().
getUnderlyingType().(IntegralType).isUnsigned()
)
and not exists(
LTExpr le | le.getLeftOperand() = arg.getAnAccess()
and le.getRightOperand().getValue() = "0"
)
and not exists(
LEExpr le | le.getLeftOperand() = arg.getAnAccess()
and le.getRightOperand().getValue() = "0"
)
and ae.getArrayOffset() = arg.getAnAccess()
select ae.getArrayOffset(),
ae.getEnclosingFunction()
20
results- Only
6
different functions
fasttrap_pid_getargdesc
1
// args: (void *arg, dtrace_id_t id, void *parg, dtrace_argdesc_t *desc)
if (probe->ftp_prov->ftp_retired != 0 ||
desc->dtargd_ndx >= probe->ftp_nargs) {
desc->dtargd_ndx = DTRACE_ARGNONE;
return;
}
ndx = (probe->ftp_argmap != NULL) ?
probe->ftp_argmap[desc->dtargd_ndx] : desc->dtargd_ndx;
Docs: get the argument description for args[X]
dtargd_ndx
is int
2
typedef struct dtrace_argdesc {
...
int dtargd_ndx; /* arg number (-1 iff none) */
...
} dtrace_argdesc_t;
ftp_nargs
is unsigned char
3
struct fasttrap_probe {
...
uint8_t ftp_nargs; /* translated argument count */
...
};
As desc->dtargd_ndx
is int
and probe->ftp_nargs
is unsigned char
if (probe->ftp_prov->ftp_retired != 0 ||
desc->dtargd_ndx >= probe->ftp_nargs) {
desc->dtargd_ndx = DTRACE_ARGNONE;
return;
}
If desc->dtargd_ndx < 0
, then desc->dtargd_ndx >= probe->ftp_nargs
is always false
ndx = (probe->ftp_argmap != NULL) ?
probe->ftp_argmap[desc->dtargd_ndx] : desc->dtargd_ndx;
If probe->ftp_argmap
isn't null
, it's possible to reach the first expression and use
desc->dtargd_ndx
with values less than 0
It's called as a C-style virtual function
dtrace_pops
4
typedef struct dtrace_pops {
...
void (*dtps_getargdesc)(void *arg, dtrace_id_t id, void *parg,
dtrace_argdesc_t *desc);
...
} dtrace_pops_t;
dtrace_pops_t
5
static dtrace_pops_t pid_pops = {
...
.dtps_getargdesc = fasttrap_pid_getargdesc,
...
};
dtps_getargdesc
might be a pointer to fasttrap_pid_getargdesc
6
prov->dtpv_pops.dtps_getargdesc(
prov->dtpv_arg,
probe->dtpr_id,
probe->dtpr_arg,
&desc
);
Upper bound check in fasttrap_pid_getargdesc
7
if (probe->ftp_prov->ftp_retired != 0 ||
desc->dtargd_ndx >= probe->ftp_nargs) {
desc->dtargd_ndx = DTRACE_ARGNONE;
return;
}
Comparing to -1
in dtrace_ioctl
8
if (desc.dtargd_ndx == DTRACE_ARGNONE)
return (EINVAL);
ndx = (probe->ftp_argmap != NULL) ?
probe->ftp_argmap[desc->dtargd_ndx] : desc->dtargd_ndx;
str = probe->ftp_ntypes;
for (i = 0; i < ndx; i++) {
str += strlen(str) + 1;
}
(void) strlcpy(desc->dtargd_native, str, sizeof(desc->dtargd_native));
- We control integer index
desc->dtargd_ndx
and array ofnull
delimited stringsprobe->ftp_ntypes
(array of chars) - We have to leak
probe->ftp_argmap[desc->dtargd_ndx]
(ndx
is integer) value intodesc->dtargd_native
str = probe->ftp_ntypes; // { 1, 1, 0, 1, 0, 2, 0, 3, 0, ...}
for (i = 0; i < ndx; i++) { // ndx is a value to leak
str += strlen(str) + 1;
}
(void) strlcpy(desc->dtargd_native, str, sizeof(desc->dtargd_native));
- We could populate
probe->ftp_ntypes
with an array of null delimited strings[1, 1, 0, 1, 0, 2, 0, 3, 0, ..., 255]
from 0 to 255 (showed as bytes)- Encode
0
for example as[1, 1, 0]
, so it's copied to the userland
- Then
ndx
equals to value instr
- Special case —
0
is"\x01\x01\x00"
- Special case —
str = probe->ftp_ntypes; // { 1, 1, 0, 1, 0, 2, 0, 3, 0, ...}
for (i = 0; i < ndx; i++) { // ^
str += strlen(str) + 1;
}
// str points to "\x01\x01\x00"
(void) strlcpy(desc->dtargd_native, str, sizeof(desc->dtargd_native));
str = probe->ftp_ntypes; // { 1, 1, 0, 1, 0, 2, 0, 3, 0, ...}
for (i = 0; i < ndx; i++) { // ^
str += strlen(str) + 1;
}
// str points to "\x01\x00"
(void) strlcpy(desc->dtargd_native, str, sizeof(desc->dtargd_native));
_dtrace_ioctl
→ DTRACEIOC_PROBEARG
switch case → fasttrap_pid_getargdesc
Available for: macOS Ventura
Impact: An app may be able to disclose kernel memory
Description: An out-of-bounds read issue existed that led to the disclosure of kernel memory. This was addressed with improved input validation.
- The bug allows reading data byte by byte in a range of 2GB
- Requires root access
Reversed fasttrap_pid_getargdesc
changes
if (probe->ftp_prov->ftp_retired != 0 ||
desc->dtargd_ndx < 0 || // added
desc->dtargd_ndx >= probe->ftp_nargs) {
desc->dtargd_ndx = DTRACE_ARGNONE;
return;
}
- Apple hasn't released the new
XNU
source code
a < b
, wherea
is signed- The comparison above happens in
IfStmt
- No
a <= 0
anda < 0
checks a
is an array index
from Variable arg
where exists(
LTExpr le |
le.getLeftOperand() = arg.getAnAccess()
and le.getParent() instanceof IfStmt
and le.getLeftOperand().
getExplicitlyConverted().
getUnderlyingType().(IntegralType).isSigned()
)
select arg
IfStmt
is if (a < b) {}
, but not a < b
in for (a = 0; a < b; a++)
from Variable arg
where not exists(
LTExpr le | le.getLeftOperand() = arg.getAnAccess()
and le.getRightOperand().getValue() = "0"
)
and not exists(
LEExpr le | le.getLeftOperand() = arg.getAnAccess()
and le.getRightOperand().getValue() = "0"
)
select arg
from Variable arg, ArrayExpr ae
where ae.getArrayOffset() = arg.getAnAccess()
select ae.getArrayOffset(),
ae.getEnclosingFunction()
from ArrayExpr ae
where ae.getFile().getAbsolutePath().
matches("%/xnu-build/xnu/%")
and not ae.getFile().getAbsolutePath().
matches("%/xnu-build/xnu/SETUP/%")
select ae.getArrayOffset(),
ae.getEnclosingFunction()
from Variable arg, ArrayExpr ae
where exists(
LTExpr le |
le.getLeftOperand() = arg.getAnAccess()
and le.getParent() instanceof IfStmt
and le.getLeftOperand().
getExplicitlyConverted().
getUnderlyingType().(IntegralType).isSigned()
)
and not exists(
LTExpr le | le.getLeftOperand() = arg.getAnAccess()
and le.getRightOperand().getValue() = "0"
)
and not exists(
LEExpr le | le.getLeftOperand() = arg.getAnAccess()
and le.getRightOperand().getValue() = "0"
)
and ae.getArrayOffset() = arg.getAnAccess()
and ae.getFile().getAbsolutePath().matches("%/xnu-build/xnu/%")
and not ae.getFile().getAbsolutePath().matches("%/xnu-build/xnu/SETUP/%")
select ae.getArrayOffset(),
ae.getEnclosingFunction()
169
results- Only
45
different functions
⚔️ OOB Read, argno
is an index on arm64
9
uint64_t
fasttrap_pid_getarg(void *arg, dtrace_id_t id, void *parg, int argno,
int aframes)
{
arm_saved_state_t* regs = find_user_regs(current_thread());
/* First eight arguments are in registers */
if (argno < 8) {
return saved_state64(regs)->x[argno];
}
Docs: get the value for an argX or args[X] variable
⚔️ OOB Read, argno
is an index on x86_64
10
uint64_t
fasttrap_pid_getarg(void* arg, dtrace_id_t id, void* parg, int argno,
int aframes)
{
pal_register_cache_state(current_thread(), VALID);
return (fasttrap_anarg(
(x86_saved_state_t*)find_user_regs(current_thread()),
1,
argno));
}
fasttrap_anarg
11
// args: (x86_saved_state_t *regs, int function_entry, int argno)
if (argno < 6)
return ((®s64->rdi)[argno]);
dtrace_pops
12
typedef struct dtrace_pops {
...
uint64_t (*dtps_getargval)(void *arg, dtrace_id_t id, void *parg,
int argno, int aframes);
...
} dtrace_pops_t;
dtrace_pops_t
13
static dtrace_pops_t pid_pops = {
...
.dtps_getargval = fasttrap_pid_getarg,
...
};
dtps_getargval
might be a pointer to fasttrap_pid_getarg
14
// func: dtrace_dif_variable
// args: (dtrace_mstate_t *mstate, dtrace_state_t *state, uint64_t v,
// uint64_t ndx)
val = pv->dtpv_pops.dtps_getargval(pv->dtpv_arg,
mstate->dtms_probe->dtpr_id,
mstate->dtms_probe->dtpr_arg, ndx, aframes);
// func: dtrace_dif_variable
// args: (dtrace_mstate_t *mstate, dtrace_state_t *state, uint64_t v,
// uint64_t ndx)
if (ndx >= sizeof (mstate->dtms_arg) / sizeof (mstate->dtms_arg[0])) {
...
dtrace_provider_t *pv;
uint64_t val;
pv = mstate->dtms_probe->dtpr_provider;
if (pv->dtpv_pops.dtps_getargval != NULL)
val = pv->dtpv_pops.dtps_getargval(pv->dtpv_arg,
mstate->dtms_probe->dtpr_id,
mstate->dtms_probe->dtpr_arg, ndx, aframes);
ndx
is an unsigned long long
, later it's converted into an int
in fasttrap_pid_getarg
, argno
argument
dtrace_dif_emulate
→ DIF_OP_LDGA
opcode → dtrace_dif_variable
→ fasttrap_pid_getarg
Almost the same code flow as in CVE-2017-13782 by Kevin Backhouse
- But you have to use a
fasttrap
provider, which allows tracing userland functions- It's possible to define a function
void foo() {}
- Trace it using DTrace:
pid$target::foo:entry { ... }
- It's possible to define a function
Code flow difference 15
pv = mstate->dtms_probe->dtpr_provider;
if (pv->dtpv_pops.dtps_getargval != NULL)
val = pv->dtpv_pops.dtps_getargval(pv->dtpv_arg,
mstate->dtms_probe->dtpr_id,
mstate->dtms_probe->dtpr_arg, ndx, aframes); // CVE-2023-28200
...
else
val = dtrace_getarg(ndx, aframes, mstate, vstate); // CVE-2017-13782
9
lines difference
Available for: macOS Ventura
Impact: An app may be able to disclose kernel memory
Description: A validation issue was addressed with improved input sanitization.
- The bug allows reading data in a range of 16GB
- Requires root access
Reversed dtrace_dif_variable
changes
if (ndx >= sizeof (mstate->dtms_arg) / sizeof (mstate->dtms_arg[0])) {
if ((ndx & 0x80000000) != 0) return 0; // added
...
dtrace_provider_t *pv;
uint64_t val;
pv = mstate->dtms_probe->dtpr_provider;
if (pv->dtpv_pops.dtps_getargval != NULL)
val = pv->dtpv_pops.dtps_getargval(pv->dtpv_arg,
mstate->dtms_probe->dtpr_id,
mstate->dtms_probe->dtpr_arg, ndx, aframes);
- Additional check added in caller function
- Callee functions are unfixed for some reason
- Apple hasn't released the new XNU source code
root
access !=kernel
access on macOSSIP
puts the whole system into a sandbox- even
root
can't load untrusted kernel extensions
- even
- + I had
App Sandbox Escape
→user to root
LPE chain
- CVE-2023-27941 matches kernel addresses from leaked data
- CVE-2023-28200 only panics the kernel
- Apple has to maintain two architectures:
x86_64
andarm64
- C-like
virtual functions
makestatic
analysis harder
- Real hackers don't leave DTrace
- Finding a memory exposure vulnerability with CodeQL
- There is no S in macOS SIP