Kỹ thuật ẩn giấu hook SSDT của rootkit
Chào các bạn, trong bài viết trước đã có lần mình đề cập đến kỹ thuật hook SSDT. Đây là một kỹ thuật hook ở mức kernel mode khá dễ thực hiện mà hiệu quả lại rất cao. Vì vậy kỹ thuật này được rootkit sử dụng rất nhiều trong thực tế nhằm nắm được quyền kiểm soát hệ thống. Tuy nhiên, qua một thời gian được sử dụng thì kỹ thuật này đã trở lên phổ biến, dễ bị phát hiện, và dễ bị gỡ bỏ. Do đó một lần nữa, rootkit cần có bước tiến hóa để thích nghi với điều kiện thực tế. Và một trong số những phương án mà rootkit sử dụng đó là ẩn giấu hành vi hook SSDT trước sự dò quét của những công cụ kiểm tra rootkit như: Gmer, PcHunter, PowerTool, …. Sau đây mình sẽ trình bày với các bạn chi tiết kỹ thuật này của rootkit.
Trước khi bắt đầu ta xem lại quá trình một hàm API được thực hiện:
Hàm WriteFile() của kernel32.dll sẽ gọi tới hàm ZwWriteFile() do ntdll.dll export. Tại đây hệ thống sẽ gọi SysEnter/Int2E để chuyển vào KernelMode kèm với System Service Number để xác định vị trí hàm NtWriteFile trong KiServiceTable. Như vậy dù Int 2E hay SysEnter quá trình hàm API được gọi sẽ phải đi đến KiSystemService() là nơi System Service Number được dùng để xác định vị trí hàm cần gọi trong KiServiceTable và gọi nó.
KiServiceTable trỏ tới một mảng chứa địa chỉ các hàm API của hệ thống, mảng này được gọi là SystemServiceDispatchTable hay SSDT. KiServiceTable nằm trong cấu trúc SystemServiceTable (SST). Một mảng gồm 4 SST tạo thành cấu trúc ServiceDescriptorTable (SDT). Trong hệ thống tồn tại 2 SDT là KeServiceDescriptorTable và KeServiceDescriptorTableShadow. KeServiceDescriptorTable chỉ có một SST chứa thông tin gồm các hàm API hệ thống do ntoskrnl.exe export. KeServiceDescriptorTableShadow có 2 SST gồm 1 SST giống với KeServiceDescriptorTable và SST còn lại chứa các hàm API hệ thống do win32k.sys export dùng để xử lý GUI.
Về lý thuyết là như vậy nhưng khi áp vào thực tế xuất hiện một số nghi vấn:
+ Làm sao biết lúc nào hệ thống sử dụng KeServiceDescriptorTableShadow hay KeServiceDescriptorTable ?
+ Khi đi vào Kernel liệu KeServiceDescriptorTable có được lấy trực tiếp từ bảng export của ntoskrnl ?
Debug KiSystemService() ta có:
Như vậy tại KiSystemService() bảng Service Descriptor Table có được từ trường Service Table trong cấu trúc _ETHREAD->_KTHREAD chứ không phải lấy từ bảng export của ntoskrnl.
System Service Number sẽ được kiểm tra để xác định index của hàm cần lấy có nằm trong phạm vi của SSDT hay không nếu ko sẽ nhảy tới nt!KiBBTUnexpectedRange. Nếu index thỏa mãn hệ thống lấy thông tin về tham số của hàm từ SSPT , địa chỉ hàm cần gọi được lấy từ SSDT dựa theo index tính được từ System Service Number. Cuối cùng là bước kiểm tra vùng lưu tham số của hàm xem ở usermode hay kernel mode. Nếu ở usermode thì tham số sẽ được copy sang kernel mode và thực hiện hàm.
Quá trình trên mới chỉ mô tả một API nào đó có index nằm trong SSDT của KeServiceDescriptorTable. Vậy nếu có một API của win32k.sys thì sao? Khi đó index hàm nằm ngoài bảng SSDT của KeServiceDescriptorTable và sẽ nhảy tới nt!KiBBTUnexpectedRange .
Lệnh CMP kiểm tra lại bit 12,13 xem có đúng là hàm cần gọi thuộc KeServiceDescriptorTable hay không. Nếu đúng, hàm PsConvertToGuiThread sẽ được gọi. Đây là một hàm undocument có nhiệm vụ thay đổi giá trị trong _ETHREAD->_KTHREAD->ServiceTable trỏ tới KeServiceDescriptorTableShadow. Sau khi thay đổi xong, giá trị index của hàm sẽ được tính toán lại dựa trên KeServiceDescriptorTableShadow.
Kết luận lại ta đã có thể trả lời cho 2 câu hỏi trên như sau:
+ Trong quá trình tìm địa chỉ hàm từ System Service Number, KeServiceDescriptorTable được lấy trong _ETHREAD->_KTHREAD->ServiceTable của chính thread đó chứ không phải lấy từ bảng export của ntoskrnl. Biết được điều này ta có thể kết luận khi khởi tạo thread mới hệ thống sẽ phải gán địa chỉ KeServiceDescriptorTable cho thread đó, đây là một điểm có thể khai thác được.
+ Nếu API cần gọi có bit 12,13 trong System Service Number xác định KeServiceDescriptorTableShadow thì lúc này hệ thống mới thực hiện việc chuyển đổi giá trị trong _ETHREAD->_KTHREAD->ServiceTable chứa địa chỉ của KeServiceDescriptorTableShadow và tiếp tục thực hiện hàm. Như vậy khi khởi tạo thread mặc định KeServiceDescriptorTable sẽ được gán cho thread và chỉ khi nào trong thread này gọi một hàm liên quan tới win32k.sys thì khi đó việc chuyển đổi sang KeServiceDescriptorTableShadow mới được thực hiện. Nếu ta có thể can thiệp vào được hàm PsConvertToGuiThread ta sẽ can thiệp được quá trình chuyển đổi SDT một luồng bất kì.
Với cơ chế hoạt động như vậy ta có thể thực hiện một kĩ thuật để làm ẩn các hàm hook SSDT. Kĩ thuật được thực hiện dựa trên việc tạo 2 fake SDT (copy lại của KeServiceDescriptorTableShadow và KeServiceDescriptorTable) sau đó hook hàm PsConvertToGuiThread và đăng kí một hàm NotifyCallback sử dụng PsSetCreateThreadNotifyRoutine. Như vậy khi một thread được tạo lên hàm CallBack của ta sẽ được gọi và thực hiện sửa trường ServiceTable trỏ tới fake KeServiceDescriptorTable. Khi luồng đó gọi một hàm liên quan tới win32k.sys, hệ thống thực hiện chuyển đổi SDT và hàm PsConvertToGuiThread được gọi. Hàm hook của ta sẽ sửa lại để ServiceTable trỏ tới fake KeServiceDescriptorTableShadow. Tất cả luồng sẽ hoạt động trong sự kiểm soát của fake SDT.
Nếu muốn hook các hàm ta sẽ thực hiện nó trên fake SDT chứ không phải SDT của hệ thống. Tất cả các luồng sử dụng fake SDT do đó tất cả tiến trình sẽ bị hook. SDT thật ko hề bị sửa đổi do đó những anti rootkit mà sử dụng KeServiceDescriptorTable lấy từ ntoskrnl để quét sẽ lấy được SDT thật và ko thể quét ra.
Một số khó khăn khi sử dụng kĩ thuật này:
- Ẩn được hook nhưng bị lộ hàm Notify
- Hook hàm PsConvertToGuiThread không đơn giản cần diasm
- Cần kết hợp thêm các kĩ thuật khác để tăng tính hiệu quả
Trước khi bắt đầu ta xem lại quá trình một hàm API được thực hiện:
Hàm WriteFile() của kernel32.dll sẽ gọi tới hàm ZwWriteFile() do ntdll.dll export. Tại đây hệ thống sẽ gọi SysEnter/Int2E để chuyển vào KernelMode kèm với System Service Number để xác định vị trí hàm NtWriteFile trong KiServiceTable. Như vậy dù Int 2E hay SysEnter quá trình hàm API được gọi sẽ phải đi đến KiSystemService() là nơi System Service Number được dùng để xác định vị trí hàm cần gọi trong KiServiceTable và gọi nó.
KiServiceTable trỏ tới một mảng chứa địa chỉ các hàm API của hệ thống, mảng này được gọi là SystemServiceDispatchTable hay SSDT. KiServiceTable nằm trong cấu trúc SystemServiceTable (SST). Một mảng gồm 4 SST tạo thành cấu trúc ServiceDescriptorTable (SDT). Trong hệ thống tồn tại 2 SDT là KeServiceDescriptorTable và KeServiceDescriptorTableShadow. KeServiceDescriptorTable chỉ có một SST chứa thông tin gồm các hàm API hệ thống do ntoskrnl.exe export. KeServiceDescriptorTableShadow có 2 SST gồm 1 SST giống với KeServiceDescriptorTable và SST còn lại chứa các hàm API hệ thống do win32k.sys export dùng để xử lý GUI.
Về lý thuyết là như vậy nhưng khi áp vào thực tế xuất hiện một số nghi vấn:
+ Làm sao biết lúc nào hệ thống sử dụng KeServiceDescriptorTableShadow hay KeServiceDescriptorTable ?
+ Khi đi vào Kernel liệu KeServiceDescriptorTable có được lấy trực tiếp từ bảng export của ntoskrnl ?
Debug KiSystemService() ta có:
Như vậy tại KiSystemService() bảng Service Descriptor Table có được từ trường Service Table trong cấu trúc _ETHREAD->_KTHREAD chứ không phải lấy từ bảng export của ntoskrnl.
System Service Number sẽ được kiểm tra để xác định index của hàm cần lấy có nằm trong phạm vi của SSDT hay không nếu ko sẽ nhảy tới nt!KiBBTUnexpectedRange. Nếu index thỏa mãn hệ thống lấy thông tin về tham số của hàm từ SSPT , địa chỉ hàm cần gọi được lấy từ SSDT dựa theo index tính được từ System Service Number. Cuối cùng là bước kiểm tra vùng lưu tham số của hàm xem ở usermode hay kernel mode. Nếu ở usermode thì tham số sẽ được copy sang kernel mode và thực hiện hàm.
Quá trình trên mới chỉ mô tả một API nào đó có index nằm trong SSDT của KeServiceDescriptorTable. Vậy nếu có một API của win32k.sys thì sao? Khi đó index hàm nằm ngoài bảng SSDT của KeServiceDescriptorTable và sẽ nhảy tới nt!KiBBTUnexpectedRange .
Lệnh CMP kiểm tra lại bit 12,13 xem có đúng là hàm cần gọi thuộc KeServiceDescriptorTable hay không. Nếu đúng, hàm PsConvertToGuiThread sẽ được gọi. Đây là một hàm undocument có nhiệm vụ thay đổi giá trị trong _ETHREAD->_KTHREAD->ServiceTable trỏ tới KeServiceDescriptorTableShadow. Sau khi thay đổi xong, giá trị index của hàm sẽ được tính toán lại dựa trên KeServiceDescriptorTableShadow.
Kết luận lại ta đã có thể trả lời cho 2 câu hỏi trên như sau:
+ Trong quá trình tìm địa chỉ hàm từ System Service Number, KeServiceDescriptorTable được lấy trong _ETHREAD->_KTHREAD->ServiceTable của chính thread đó chứ không phải lấy từ bảng export của ntoskrnl. Biết được điều này ta có thể kết luận khi khởi tạo thread mới hệ thống sẽ phải gán địa chỉ KeServiceDescriptorTable cho thread đó, đây là một điểm có thể khai thác được.
+ Nếu API cần gọi có bit 12,13 trong System Service Number xác định KeServiceDescriptorTableShadow thì lúc này hệ thống mới thực hiện việc chuyển đổi giá trị trong _ETHREAD->_KTHREAD->ServiceTable chứa địa chỉ của KeServiceDescriptorTableShadow và tiếp tục thực hiện hàm. Như vậy khi khởi tạo thread mặc định KeServiceDescriptorTable sẽ được gán cho thread và chỉ khi nào trong thread này gọi một hàm liên quan tới win32k.sys thì khi đó việc chuyển đổi sang KeServiceDescriptorTableShadow mới được thực hiện. Nếu ta có thể can thiệp vào được hàm PsConvertToGuiThread ta sẽ can thiệp được quá trình chuyển đổi SDT một luồng bất kì.
Với cơ chế hoạt động như vậy ta có thể thực hiện một kĩ thuật để làm ẩn các hàm hook SSDT. Kĩ thuật được thực hiện dựa trên việc tạo 2 fake SDT (copy lại của KeServiceDescriptorTableShadow và KeServiceDescriptorTable) sau đó hook hàm PsConvertToGuiThread và đăng kí một hàm NotifyCallback sử dụng PsSetCreateThreadNotifyRoutine. Như vậy khi một thread được tạo lên hàm CallBack của ta sẽ được gọi và thực hiện sửa trường ServiceTable trỏ tới fake KeServiceDescriptorTable. Khi luồng đó gọi một hàm liên quan tới win32k.sys, hệ thống thực hiện chuyển đổi SDT và hàm PsConvertToGuiThread được gọi. Hàm hook của ta sẽ sửa lại để ServiceTable trỏ tới fake KeServiceDescriptorTableShadow. Tất cả luồng sẽ hoạt động trong sự kiểm soát của fake SDT.
Nếu muốn hook các hàm ta sẽ thực hiện nó trên fake SDT chứ không phải SDT của hệ thống. Tất cả các luồng sử dụng fake SDT do đó tất cả tiến trình sẽ bị hook. SDT thật ko hề bị sửa đổi do đó những anti rootkit mà sử dụng KeServiceDescriptorTable lấy từ ntoskrnl để quét sẽ lấy được SDT thật và ko thể quét ra.
Một số khó khăn khi sử dụng kĩ thuật này:
- Ẩn được hook nhưng bị lộ hàm Notify
- Hook hàm PsConvertToGuiThread không đơn giản cần diasm
- Cần kết hợp thêm các kĩ thuật khác để tăng tính hiệu quả