Why <blank> Gets You Root
› tracking down the cause of a serious authentication flaw
11/29/2017
love these blog posts? support my tools & writing on patreon! Mahalo :)
Background
In case you haven't heard the news, there is a massive security flaw which affects the latest version of macOS (High Sierra). The bug allows anybody to log into the root account with a blank, or password of their choosing. Yikes!
Apparently this vulnerability was first posted, rather innocuously, to Apple's very own Developer Forums...in order to aid a user having account access issues:
However, the flaw only gained wider public attention, when Lemi Orhan Ergin (@lemiorhan) posted a tweet stating that, "we noticed a *HUGE* security issue at MacOS High Sierra...":
I was quite intrigued by this bug, so decided to reverse macOS to track down its root cause. In this blog post, I reveal my findings, and uncover the underlying reason for the bug.
So, without further ado, let's dive right in!
Digging Deeper
First, let's look what's happening at a high level. When a user (or attacker) attempts to log into an account that is not currently enabled (i.e. root), for some unknown reason, the system will naively create that account with whatever password the user specifies...even if that password is blank. Then the user (or attacker) can readily log into that account:
This two step process explains why to perform this attack, one has to hit enter or click 'Unlock' twice:
It also turns out that if users have services such as screen sharing enabled, this attack can be performed remotely!
Of course, one should not be able to randomly enable accounts, especially the all powerful root account (remotely!), without providing any sort of authentication. So, wtf is going on? Time to dig into macOS to see what's going on behind the scenes!
When an user (or an attacker) tries to authenticate to an account, this is handled by the 'opendirectory' daemon (opendirectoryd). By debugging this daemon, we can view the sequence of function calls which occurs when the daemon receives a mach XPC authentication message:
# ps aux | grep opendirectoryd
root 70 /usr/libexec/opendirectoryd
lldb -p 70
...
(lldb) bt
* frame #0: opendirectoryd`od_verify_crypt_password
frame #1: PlistFile`___lldb_unnamed_symbol26$$PlistFile
frame #2: PlistFile`odm_RecordVerifyPassword
frame #3: opendirectoryd`___lldb_unnamed_symbol37$$opendirectoryd
frame #4: opendirectoryd`___lldb_unnamed_symbol313$$opendirectoryd
We'll start at the odm_RecordVerifyPassword function. This function is implemented in the PlistFile binary. This bundle (library) is dynamically loaded into opendirectoryd, from /System/Library/OpenDirectory/Modules/PlistFile.bundle:
(lldb) image list
[ 0] 50686B40-3B06-347D-B906-DCEF1D9F10E1 0x00000001041e5000 /usr/libexec/opendirectoryd
...
[188] A38BC5A0-67AA-3D75-89AD-57A7DF6D20BE 0x000000010447f000 /System/Library/OpenDirectory/Modules/PlistFile.bundle/Contents/MacOS/PlistFile
Setting a breakpoint on the odm_RecordVerifyPassword function, we can dump its arguments (passed via RDI, RSI, RDX, RCX):
Process 70 stopped
* thread #15, stop reason = breakpoint 1.1
PlistFile`odm_RecordVerifyPassword:
-> 0x10448e50b: pushq %rbp
(lldb) po $rdi
<OS_od_module: 0x7fcb0dc29110>
(lldb) po $rsi
<OS_od_connection: 0x7fcb0dc26cb0>
(lldb) po $rdx
<OS_od_request: 0x7fcb0dc78d30>
(lldb) po $rcx
<OS_od_moduleconfig: 0x7fcb0dc203b0>
Looking at it's decompilation, we can see it invokes another function: sub_18f1:
sub_18f1(&var_818, odconnection_get_context(rbx), r13);
The final parameter passed to this function (R13), is a dictionary containing information about account the user (or attacker) is attempting to authenticate to:
(lldb) po $r13
{
"dsAttrTypeStandard:AppleMetaNodeLocation" = (
"/Local/Default"
);
"dsAttrTypeStandard:GeneratedUID" = (
"FFFFEEEE-DDDD-CCCC-BBBB-AAAA00000000"
);
"dsAttrTypeStandard:Password" = (
"*"
);
"dsAttrTypeStandard:RecordName" = (
root,
"BUILTIN\\Local System"
);
"dsAttrTypeStandard:RecordType" = (
"dsRecTypeStandard:Users"
);
"dsAttrTypeStandard:UniqueID" = (
0
);
}
Note the value for dsAttrTypeStandard:Password key: *. We'll see this value later!
Next, odm_RecordVerifyPassword invokes another helper function: sub_826b, which in turn invokes sub_5192. A string in the decompilation of this function, states it will "read shadowhash data" ...from the account that the user (or attacker) is trying to log in to. This 'shadowhash data' is stored in the 'dsAttrTypeNative:ShadowHashData' key:
The 'shadowhash' for a user can be viewed from the terminal via the dscl . -read /Users/<user> command or directly by reading it from /private/var/db/dslocal/nodes/Default/users/<user>:
$ dscl . -read /Users/user
...
AuthenticationAuthority: ;ShadowHash;HASHLIST:
<SALTED-SHA512-PBKDF2,SRP-RFC5054-4096-SHA512-PBKDF2>
;Kerberosv5;;user@LKDC:SHA1.F69BF62F41274B2B983399C0D143CD33961...
GeneratedUID: A39EF7FC-E5B1-46B8-AB47-3C2B7DA49425
It should be noted that for enabled accounts, such as the user's account, sub_5192 will succeed ...as this 'shadowhash' data exists. However, for disabled accounts, (such as root account that is being targeted), this information is not present:
$ dscl . -read /Users/root | grep ShadowHash | wc
0 0 0
When 'shadowhash' data does not exist, sub_5192 will fail (returning 0x0). This causes an 'else' clause to be executed in the sub_826b function:
rax = sub_5192(var_98, r15, r12, r14, &var_88, &var_80);
if (rax != 0x0) {
//found shadow hash data
}
//no shadow hash data found
else {
//read 'dsAttrTypeStandard:Password'
rax = odproplist_get_array(r12, *_kODAttributeTypePassword);
...
var_41 = 0x0;
var_54 = 0x1388;
if (od_verify_crypt_password(var_70, rax, var_60, &var_54, &var_41) != 0x0) {
//upgrade password
sub_13d00(arg7, var_60);
sub_14324(var_70, var_A0, var_68, var_50, r15, var_60, arg7);
...
In the 'else', the code first reads in the value from the kODAttributeTypePassword (dsAttrTypeStandard:Password) key. Then, it invokes the od_verify_crypt_password function to verify that the password passed in by the user (or attacker) matches that password for the account. For example, if one tries to log into the (disabled) root account with the password 'hunter2', od_verify_crypt_password is invoked with '*' (the dsAttrTypeStandard:Password value for the root account) and 'hunter2':
Process 70 stopped
* thread #12, stop reason = breakpoint 1.1
opendirectoryd`od_verify_crypt_password:
(lldb) po $rdi
<OS_od_request: 0x7fcb0f2625e0>
(lldb) po $rsi
<__NSCFArray 0x7fcb0f2511b0>(
*
)
(lldb) po $rdx
hunter2
If we step over the call, it returns a non-zero value (al = 0x1)....implying success? Interesting!
(lldb) reg read al
al = 0x01
Since a non-zero value was returned and no other checks are performed, the code executes logic that assumes a valid password was provided (even though this was not the case!) Specifically various methods such as sub_13d00 are invoked. As the debug log statements in the decompilation show, these will perform an upgrade from a crypt password to a shadowhash or securetoken:
"found crypt password in user-record - upgrading to shadowhash or securetoken"
If we look at what these 'upgrade' subroutines (such as sub_13d00) are invoked with, it's with the password we provided (i.e. 'hunter2'):
Process 70 stopped
* thread #10, stop reason = breakpoint 2.1
PlistFile`___lldb_unnamed_symbol26$$PlistFile:
-> 0x104487552 <+743>: callq 0x104492d00
0x104487557 <+748>: subq $0x8, %rsp
0x10448755b <+752>: movq -0x70(%rbp), %rdi
0x10448755f <+756>: movq -0xa0(%rbp), %rsi
(lldb) po $rsi
hunter2
This new 'user-specified' value is then converted to a shadowhash/securetoken, then saved for the account (i.e. for root). Thus, the user (or attacker) can now log in, as the account is accessible with the password they specified! #fail
Let's recap. When a user (or attacker) attempts to authenticate to an account with any password (including blank):
- for accounts that are disabled (i.e. don't have 'shadowhash' data) macOS will attempt to perform an upgrade
- during this upgrade, od_verify_crypt_password returns a non-zero value and no other checks are performed, so the code assumes success
- the 'new' user-provided password is then upgraded (shadowhash/securetoken) and saved for the account
...this explains (mostly) why the root account can be activated and accessed with an arbitrary (or blank) password.
The only question remaining (well other than how the #$%@ this bug was made it thru QA testing in the High Sierra release), is why the od_verify_crypt_password function does not fail? Or if it does fail, why is that not detected? Let's take a closer look at this now.
As its name implies, the od_verify_crypt_password should verify that a user (or attacker) specified password is valid for an account. For example, when we try to authenticate against the disabled root account with 'hunter2' od_verify_crypt_password should tell us to simply GTFO.
The od_verify_crypt_password function is implemented directly in the 'opendirectory' daemon (opendirectoryd). As previously mentioned it is invoked by the PlistFile bundle, specifically the 'sub_826b' function:
//sub_826b
//check password and upgrade if necessary
if (od_verify_crypt_password(var_70, rax, var_60, &var_54, &var_41) != 0x0)
{
//upgrade password
sub_13d00(arg7, var_60);
sub_14324(var_70, var_A0, var_68, var_50, r15, var_60, arg7);
}
We already noted it is invoked with various parameters such as the account's password hash and the user/attacker specified password. However, the 4th parameter (var_54) is also of importance! Before the call, it set to 0x1388:
var_54 = 0x1388;
if (od_verify_crypt_password(var_70, rax, var_60, &var_54, &var_41) != 0x0){
...
Looking up 0x1388 (5000 decimal) on osstatus.com, reveals this value corresponds to 'kODErrorCredentialsInvalid':
To perform the actual password verification the od_verify_crypt_password function invokes crypt_verify. This function is passed the account password hash (e.g. for the disabled root account; '*'), the provided password (e.g. 'hunter2'), and also the var_54 parameter that was passed into od_verify_crypt_password. This parameter is saved into the R14 register, and is set to 0x0 if and only if some string comparison holds true:
int _crypt_verify(int arg0, int arg1, int arg2, int arg3) {
r12 = arg3;
r14 = arg2;
...
if (strcmp(&var_130, r13) == 0x0) {
*(int32_t *)r14 = 0x0;
}
Thru static and dynamic analysis, we can determine that (as expected), this string comparison is comparing a hash of the provided password, with the hash of the account's actual password. In other words, it is verifying that the password (hashes) match:
Process 70 stopped
opendirectoryd`crypt_verify:
-> 0x104243f3c <+965>: callq 0x104249e8a; symbol stub for: strcmp
(lldb) x/s $rdi
0x70000665eec0: "*.dAJ47YHEIRE"
(lldb) x/s $rsi
0x7fcb0ddcb851: "*"
As these strings clearly don't match, the strcmp won't succeed (i.e. it won't == 0x0). Thus, the passed in parameter we are tracing won't be set to 0x0.
At this point, we have a clear understanding of the purpose of this parameter. It is a pointer to a variable, passed in from the od_verify_crypt_password that is set to 0x0 in the crypt_verify function if and only if the password (hashes) match. As such, we can imagine the following pseudo code:
//verify
// 'match' will be set to 0x0 if verification is ok!
int match = kODErrorCredentialsInvalid;
od_verify_crypt_password(accountHash, providedPassword, &match, ...);
....
//verify by checking hashes
// 'match' will be set to 0x0 if verification is ok!
if(strcmp(providedPWHash, accountPWHash, user) == 0x0) {
*match = 0x0;
}
As we pointed out earlier, only the return value of od_verify_crypt_password is checked....NOT THE RESULT OF THE ACTUAL VERIFICATION!! (i.e. the 'match' variable, var_54).
This can be confirmed by examining the following decompilation, which shows the call to od_verify_crypt_password. Note that the 'match' variable (var_54), is never checked after the call. Instead, the upgrade functions (sub_13d00, sub_14324), are erroneously invoked:
And Apple Responds!
Shortly after posting this blog, Apple released a patch for both macOS 10.13 and 10.13.1. The patch can be directly downloaded from Apple's support site:
Or, it should show up automatically as a security update (in the macOS app store):
The bug was assigned CVE-2017-13872, and Apple states in the security release notes that it was simply "a logic error [that] existed in the validation of credentials." Their patch, they note, "improved credential validation."
You may be wondering how did they patch this bug? ...and was the underlying issue we uncovered in this blog correct?
Comparing the unpatched and patched PlistFile binary, we can see Apple added code to detect invalid credentials (i.e. when a non-authenticated attacker tries to set the root password):
Specifically, as expected, they now check the result of the 'match' variable after the call to od_verify_crypt_password:
lea rbx, qword [rbp+var_54] ;load addr of 'match' in rbx
mov rcx, rbx ;move into arg4 for call
call imp___stubs__od_verify_crypt_password
mov ecx, dword [rbx] ;get value of 'match'
test ecx, ecx ;is it 0x0?
jne noMatch ;no, then bail!
Thus, our analysis proved to be correct! Phew 😅
Sadly, as is often the case with Apple patches, it seemed to have some serious issues. First, it broke file sharing for various users:
As pointed out to me (thanks @alvarnell), this incompatibility was quickly fixed with a new patch (bringing the build to 17C1003).
Worse yet, as reported by Wired, if a user on macOS 10.13 applied the patch, then later upgraded to macOS 10.13.1, the bug would be reintroduced:
The wise Pepijn Bruienne (@bruienne), noted this is likely due to the fact that Apple "didn't bump the build number" nor "didn't roll the #iamroot patch into 10.13.1":
Users have also reporting that after applying the patch, a reboot is required!
My good friend Thomas Reed (@thomasareed) published a good writeup comprehensively summarizing Apple's missteps deploying this patch.
Conclusion
Well, that's a wrap! In this blog we reversed various components of the 'opendirectory' daemon, to reveal the underlying cause of the now infamous #iamroot bug - before Apple released a patch! We determined that Apple forgot to check the value of an essential variable that held the result of an account verification.
And once a patch was released, we reversed it to confirm that our findings were correct. Hooray!
....until the next bug, adios!
love these blog posts & tools? you can support them via patreon! Mahalo :)