Why <blank> Gets You Root

› tracking down the cause of a serious authentication flaw







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 () 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 : callq 0x104492d00

0x104487557 : subq $0x8, %rsp

0x10448755b : movq -0x70(%rbp), %rdi

0x10448755f : movq -0xa0(%rbp), %rsi



(lldb) po $rsi

hunter2



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

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 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! #failLet's recap. When a user (or attacker) attempts to authenticate to an account with any password (including blank):...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 : 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):

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



love these blog posts & tools? you can support them via patreon ! Mahalo :)

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!