DLL 劫持是一种强制合法应用程序运行恶意代码的技术,至少已经使用了十年左右。在本文中,我们简要介绍了 DLL 劫持技术,然后总结了 MITRE 在过去十年中记录的数十种该技术的使用情况。重点包括被滥用的特定可执行文件、有关劫持实施具体方式的统计数据,以及一些涉及的恶意 DLL 的内部结构。然后,我们讨论了应用程序开发人员可以使用哪些工具来防止恶意行为者以这种方式滥用其合法应用程序,并为其中一种工具提供了一个概念验证,该工具可以利用数字签名的部分功能,而无需与证书颁发机构打交道。
各种来源对“DLL 劫持”以及相关术语“DLL 侧载”的定义不同。这两个术语的不同定义部分重叠,可能会造成一些混淆。例如,MITRE认为侧载“利用加载程序使用的 DLL 搜索顺序,将受害应用程序和恶意负载并排放置”,而信息安全公司 Mandiant 至少在一份报告中将DLL 侧载定义为 WinSxS 的滥用,具体如下:
在本文中,我们将“DLL 劫持”定义为任何滥用良性可执行文件的动态库依赖项的执行流劫持技术,无论这些依赖项是在某种可执行文件清单中声明的还是在运行时加载的。这种技术至少从 2013 年就开始出现;上文提到的 Mandiant 报告指出,2013 年的一次鱼叉式网络钓鱼攻击针对的是中国政治权利活动家,该攻击利用 Windows ActiveX 控件中的漏洞 (CVE-2012-0158) 从 Office 2003 Service Pack 2 更新中删除良性可执行文件,然后使其加载恶意 DLL。从那时起,以 DLL 劫持为特征的攻击链就一直稳步发展 - 主要由国家支持的行为者(例如Lazarus Group和Tropic Trooper)使用,偶尔也被网络犯罪行业使用,与QBot 信息窃取程序和Dridex 银行木马等结合使用。
DLL 劫持的三个主要用例是逃避、持久性和权限提升。
逃避检测可能是因为侧载 DLL 将作为最初源自良性可执行文件的进程映像的一部分运行。乍一看,该进程似乎不那么可疑;在某些病态情况下,它甚至可能在某种允许列表中,从而免于审查。根据进程的声誉而不是行为来判断进程的安全过滤器可能会将被劫持的进程错误地归类为良性,而事实并非如此。这是一个一般原则的例子:当你信任某物(或某人)时,你不仅需要担心它会故意背叛你,还需要担心它会被操纵和混淆。
如果在受害系统正常运行期间定期执行良性可执行文件,则可能导致持久性。作为攻击者,自然的想法是使用在启动时自动启动的程序,但稍加思考就会发现,针对受害机器上的默认 Web 浏览器或其他一些常用软件也可以奏效。
如果良性可执行文件具有普通进程所不具备的权限,则可以实现权限提升。首先想到的例子是管理员权限:正如微软所说,“UAC 中的同桌面提升不是安全边界”;DLL 劫持是攻击者滥用这一事实的一种方式。在其他情况下,某些软件可能会在文件、驱动程序和其他对象周围实施临时安全边界,只有特定进程才允许读取或修改。劫持这些特定进程将允许绕过该限制。
为了了解 DLL 劫持的现状,我们分析了不同活动对此技术的几十种使用,MITRE 将其归类为“DLL 侧加载”,其中包括所使用的具体劫持技术,例如恶意 DLL 的位置、加载方式、滥用了哪些良性可执行文件,以及恶意 DLL 的构建方式的内部位和字节。
到目前为止,这些活动中记录的最常见策略是将已知的良性应用程序和恶意 DLL 捆绑在一起,然后将它们放在同一个文件夹中并执行良性应用程序。超过一半的受访活动使用了这种技术。我们在下表中提供了这些劫持实例以及每个实例中被滥用的良性可执行文件。
该表格中第一个引人注目的特征是攻击者对“听起来可信”的应用程序的迷恋:Google、Microsoft、Adobe。毕竟,攻击者没有防御者如何行动的精确威胁模型,但也许他们认为,如果他们滥用这些知名供应商的应用程序,他们就能获得优势。当处理具有广泛安装基础的流行应用程序时,防御者自然会更加担心误报(根据传说,在遥远的过去,“受信任”的应用程序和协议通常完全免于检查)。基本上,防御者越重视可执行文件的信誉,这种技术就越有价值。
如果我们抛开那些备受推崇和广受欢迎的应用程序的持续滥用,剩下的数据中还是有一些小趋势的。首先是 AV 产品被反复滥用,但另一个令人好奇的现象是应用程序被滥用,而不太在意它们的状态或来源,这导致了如下场景:
Proofpoint 报告称, 2016 年发生的一次攻击滥用了 AV 产品 Norman Safeground。该公司两年前被 AVG收购,而 AVG 又在攻击发生时被 Avast Software 收购;原始产品被纳入更名后的 AVG 产品。
Forcepoint描述的另一起 2016 年攻击滥用了 Java 6 运行时中的一个文件 —
java-rmi.exe
— 一个被错误编译到 Java 运行时中的二进制文件,该文件的存在自 2007 年以来一直被视为一个错误。2013 年,Java 6正式终止使用;然后在 2015 年,有关存在的错误报告java-rmi.exe
得到解决,该文件从 Java 的所有未来版本中删除。尽管如此,攻击者在一年后仍毫无顾忌地打包该文件并滥用它来加载恶意 DLL。
另一个趋势是捆绑可执行文件,这些可执行文件是 Windows 操作系统的一部分,或者在受害者机器文件中非常常见。这可以在攻击链滥用Windows 凭据备份和恢复向导或RFS重新密钥向导的副本的活动中看到。
Red Canary 的Dridex 报告似乎认为,过于关注特定的被滥用的良性可执行文件可能会带来不利影响:
一个自然而然的问题是如何寻找易于被动态库劫持的可执行文件。一个众所周知的可靠技巧是通过进程监视工具运行可执行文件,并专门监视某个位置不存在的 DLL 文件的查找失败事件。这表明有人可以在那里插入他们自己的恶意版本供应用程序查找。这个审查程序可以使用例如 ProcMon、过滤器Path ends with .dll
和Result is NAME NOT FOUND
或一些等效程序来完成。不幸的是,这种方法的扩展性并不好,即使存在一些自动化方法,比如Spartacus 项目。特定情况可能易于利用 EDR 遥测进行搜索——例如,过滤进程从同一目录加载缺少已知正确签名的 DLL 的情况,如此处所建议的。并非所有动态库劫持都满足这些条件,但很多都满足。最后,另一种选择是利用hijacklibs存储库中提供的优秀资源。这些目录对可劫持的 DLL 进行分类,包括其版本信息、预期签名信息等。该信息可用于搜寻,例如通过查询声明特定发布者或版本信息但具有可疑修改的哈希值或缺少签名的文件。
我们研究了劫持中使用的一些恶意制作的 DLL 的内部汇编,并确定了技术主题和模式。例如,对 DLL 使用混淆工具(“打包程序”、“加密程序”)的现成支持较少。通常情况下,当威胁行为者认为某些代码或数据没有达到应有的混淆程度时,他们会使用 XOR 循环。
上图所示的特定 DLL 是恶意制作的dbgeng.dll
和 版本,导出相对较少。如果仔细观察,您会发现,在底层,其中两个(DebugConnect
和DebugCreate
)实际上指向同一个函数。
如果这看起来很奇怪,那么就有一个恶意制作的版本jli.dll
,其中不只是 2 个函数 — — 几乎每个函数都指向恶意代码:
恶意制作的版本lbtserv.dll
也有许多指向同一目标的导出。它们不是指向恶意代码,而是全部指向空函数存根。您可以自行判断哪个看起来更可疑:
最后,恶意精心制作的version.dll
包含微妙的调用vresion
而不是version
:
在本节中,我们将深入探讨应用程序开发人员可用的预防工具和方法,以防止恶意行为者使用此技术成功滥用他们的应用程序。
在主流操作系统中,应用程序声明动态库依赖项的惯用方法是通过其标头中的某种静态编译数据,例如 Windows 操作系统过去使用的 PE 格式中包含的导入表。这些格式很简单,只允许开发人员命名他们想要加载的库,而不需要进行任何额外的验证。从那里开始,操作系统会处理所有事情并指示允许劫持的行为——标准搜索顺序和加载具有正确名称的第一个库,而无需任何进一步的验证。
需要明确的是,解决此类问题所需的技术是存在的。人们很容易开始考虑空想的系统改革:如果每个人都对其 DLL 进行数字签名,如果每个可执行文件都知道它试图加载的 DLL 是谁的,并验证签名……当然,没有什么比空想的系统改革真正发生并让一整类安全问题消失更好的了,但在此之前,我们必须使用我们现有的适度工具来处理当前未改革世界中的问题。
由于通过可执行头声明依赖项会立即允许劫持,因此在开发人员级别处理此问题似乎需要在运行时加载所有可能的库。当然,如果您调用,这只会再次调用通常的搜索顺序。这里LoadLibrary("some.dll")
记录了一种适用于 Windows 的快速而肮脏的解决方法,即让应用程序首先调用。这会从 DLL 搜索路径中删除当前工作目录,因此如果确实需要从当前目录加载任何 DLL,则必须获取其完全限定路径并明确提供给它。相关的黑客是调用。这会将当前目录移到搜索顺序的底部。如果您足够偏执,担心在当前目录之外的搜索顺序中的某处存在恶意制作的 DLL,则可能希望在所有对的调用中使用完全限定路径,或者使用允许您指定可以从哪些库加载 DLL。SetDllDirectory("")
LoadLibrary
SetSearchPathMode (BASE_SEARCH_PATH_ENABLE_SAFE_SEARCHMODE | BASE_SEARCH_PATH_PERMANENT)
LoadLibrary
LoadLibraryEx
不幸的是,即使你控制了 DLL 的加载位置,仍然有足够的空间被劫持。其根本原因是,加载的库在文件系统中的位置并不能保证任何事情。虽然很可能没有人会直接篡改C:WindowsSystem32ws2_32.dll
,但如果加载的库是应用程序定制的,并且通常从当前目录加载,那就另当别论了。在这种情况下,基于路径的验证将无法在同一目录中捕获良性应用程序和恶意库的典型恶意“捆绑包”。毕竟,恶意库正好位于应用程序期望的位置。
要解决这个问题,需要处理数字签名或等效解决方案。显然,操作系统提供了一些便利措施来做到这一点(例如,LoadLibraryEx
有一个标志要求对目标 DLL 进行签名 — LOAD_LIBRARY_REQUIRE_SIGNED_TARGET
),但作为开发人员,这似乎仍然是一个重大障碍,需要花费大量时间和资源在证书颁发机构进行注册。幸运的是,可以找到一种解决方法。创建 DLL 时,您可以使用自签名证书中的私钥对其进行签名,然后将证书与 DLL 一起发布。从可执行文件加载该 DLL 的任何人(包括您)都可以首先验证证书链是否在内部签出。
请注意,攻击者仍然可以制作自己的恶意可执行文件版本(这可能是微软在介绍 .NET 的类似功能“强名称程序集”时表示“不要依赖强名称来确保安全。它们仅提供唯一身份”的原因之一);但伪造的可执行文件将具有未知的哈希值,并且不会享有原始可执行文件的良好声誉。另请注意,替换证书将破坏与以前编译的可执行文件和 DLL 的兼容性。
为了演示其工作原理,我们在下面提供了一个概念验证程序,该程序可以sample.dll
使用非常简化的自制 Authenticode 对应程序对简单的 DLL 进行签名 — 它使用 OpenSSL 计算整个文件内容的签名,然后将签名添加为覆盖。frontloaded.exe
然后,一个单独的可执行文件 ( ) 对签名的 DLL ( ) 执行安全加载sample.dll
,首先提取 DLL 签名并验证它,然后才正确加载 DLL。如果签名验证失败,则可执行文件会崩溃(抛出异常)。
用于签名程序的 Rust 代码如下。
use openssl::x509::X509;
use openssl::sign::Verifier;
use openssl::pkey::PKey;
use openssl::sign::Signer;
use openssl::rsa::Rsa;
use openssl::hash::MessageDigest;
use std::fs;
use libloading::{Library, Symbol};
use clap::{Command, Arg};
use anyhow::{Result,anyhow};
const SIG_LEN : usize = 512;
pub fn secure_load_library(cert: &str, dll_name: &str) -> Result<Library> {
match verify_file(cert,dll_name)? {
true => unsafe { Ok(Library::new(dll_name)?) },
false => Err(anyhow!("Signature verification failed"))
}
}
pub fn sign(pem_key: &str, data: &[u8]) -> Result<Vec<u8>> {
let pkey =
Rsa::private_key_from_pem(pem_key.as_bytes())
.and_then(|x| PKey::from_rsa(x))?;
let signature = Signer::new(MessageDigest::sha256(), &pkey)
.and_then(|mut x| {x.update(data)?; Ok(x)})
.and_then(|x| {x.sign_to_vec()})?;
Ok(signature)
}
pub fn verify(cert: X509, data: &[u8], sig: &[u8]) -> Result<bool> {
let public_key = cert.public_key()?;
let verification_status = Verifier::new(MessageDigest::sha256(), &public_key)
.and_then(|mut x| {x.update(data)?; Ok(x)})
.and_then(|x| x.verify(sig))?;
Ok(verification_status)
}
pub fn sign_file(key_path: &str, fname: &str, fname_new: &str) -> Result<()> {
let data = fs::read(fname)?;
let sig = sign( &fs::read_to_string(key_path)?, &data)?;
let signed_data : Vec<u8> =
data.into_iter().chain(sig.into_iter()).collect();
fs::write(fname_new, signed_data)?;
Ok(())
}
pub fn verify_file(cert: &str, fname: &str) -> Result<bool> {
let cert = X509::from_pem(cert.as_bytes())?;
let mut data = fs::read(fname)?;
let sig = data.split_off(data.len()-SIG_LEN);
verify(cert, &data, &sig)
}
CLI:
fn main() -> Result<()> {
let matches = Command::new("Signing Tool")
.version("1.0")
.about("Simple homebrew implementation of file signing using an overlay.")
.arg(
Arg::new("source_path")
.short('s')
.long("source")
.value_name("SOURCE_PATH")
.help("Path to the unsigned file")
.required(true)
)
.arg(
Arg::new("target_path")
.short('t')
.long("target")
.value_name("TARGET_PATH")
.help("Signed file will be written to this path.")
.required(true)
)
.arg(
Arg::new("key_path")
.short('k')
.long("key")
.value_name("KEY_PATH")
.help("Path to the signing key (PEM format)")
.required(true)
)
.get_matches();
let unsigned_path = matches.get_one::<String>("source_path").unwrap();
let signed_path = matches.get_one::<String>("target_path").unwrap();
let key_path = matches.get_one::<String>("key_path").unwrap();
sign_file(&key_path, &unsigned_path, &signed_path)
}
(相当简单的) DLL:
pub fn add(x: i32, y: i32) -> i32 {
x+y
}
执行加载的可执行文件:
use crate::minisign::{secure_load_library,FunctionRetrieve};
use anyhow::Result;
const CERT : &str = include_str!("cert.pem");
mod minisign;
fn main() -> Result<()> {
let lib = secure_load_library(CERT, "sample.dll")?;
let add = lib.get_proc_addr("add")?;
println!("{}", add(5,6));
Ok(())
}
这种干净的呼叫get_proc_addr
实际上需要一个临时解决方案,我们在下面为那些非常好奇想知道香肠是如何制作的人提供了解决方案。
pub trait FunctionRetrieve {
fn get_proc_addr<'a>(&'a self, func_name: &str) -> Result<Symbol<'a, Symbol<'a, extern "C" fn(i32, i32) -> i32 ;
}
impl FunctionRetrieve for Library {
fn get_proc_addr<'a>(&'a self, func_name: &str) -> Result<Symbol<'a, Symbol<'a, extern "C" fn(i32, i32) -> i32 {
unsafe {
Ok(self
.get::<Symbol<extern "C" fn(i32,i32) -> i32 (func_name.as_bytes())?
)
}
}
}
#[no_mangle]
pub fn add(x: i32, y: i32) -> i32 {
x+y
}
#[no_mangle]
pub fn add(x: i32, y: i32) -> i32 {
x+y
}
#[no_mangle]
pub fn add(x: i32, y: i32) -> i32 {
x+y
}
强制执行数字签名(无论是由证书颁发机构支持还是如上所述)是一种强大的解决方案 — 但前提是您可以应用它,如果您动态加载由某些第三方或 Microsoft 未签名的库(存在数量惊人的此类库),情况可能并非如此。在后一种情况下,前面LoadLibrary
提到的提供完全限定名称的缓解技术通常(但并非总是)对于由此产生的漏洞有效,因为它迫使攻击者从其原始位置加载恶意制作的动态库。对于 MS DLL,恶意行为者通常不会倾向于直接篡改它们。
只要威胁行为者认为,通过在源自“受信任”可执行文件的进程中运行代码,他们可以在特权和规避方面获得优势,DLL 劫持技术就可能继续存在。尽管存在许多可能的变体,但根据MITRE 的 10 年数据,最简单的变体——“同一文件夹中的良性可执行文件和恶意库”包——是最受欢迎的。
有一些实用且严密的方法来对付这种恶意技术,但实用方法并不严密,而严密的方法并不实用。开发人员可以要求从特定的硬编码路径加载库,甚至可以为他们自己的可执行库生态系统强制执行内部信任链,正如我们所展示的那样。这部分缩小了可能的 DLL 劫持变体的空间,但并非完全缩小。另一方面,如果每个人都以适当的证书颁发机构验证的通常方式签署他们的二进制文件,并在 DLL 加载时验证签名,那么问题就很少了。但是以“如果每个人”开头的解决方案通常只有在政府或垄断企业强制执行时才实用,而且通常即使在那时也不实用。
在当前的计算环境中,进程边界通常不被视为安全边界。鉴于这一点以及由此导致的 DLL 劫持和其他类似技术的流行,防御者在判断进程行为时应保持警惕,不要过分强调可执行文件的信誉。我们无法控制威胁行为者对在所谓的“受信任进程”中运行代码的迷恋,但我们可以有效地控制这种迷恋在多大程度上浪费时间。
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...