看到前辈们相关的文章,不太明白什么是句柄降权,于是专门去学习一下,过程有一点波折。
句柄降权
什么是句柄
当一个进程利用名称来创建或打开一个对象时,将获得一个句柄,该句柄指向所创建或打开的对象。以后,该进程无须使用名称来引用该对象,使用此句柄即可访问。这样做可以显著地提高引用对象的效率。句柄是一个在软件设计中被广泛使用的概念。例如,在C运行库中,文件操作使用句柄来表示,每当应用程序创建或打开一个文件时,只要此创建或打开操作成功,则C运行库返回一个句柄。以后应用程序对文件的读写操作都使用此句柄来标识该文件。而且,如果两个应用程序以共享方式打开了同一个文件,那么,它们将分别得到各自的句柄,且都可以通过句柄操作该文件。尽管两个应用程序得到的句柄的值并不相同,但是这两个句柄所指的文件却是同一个。因此,句柄只是一个对象引用,同一个对象在不同的环境下可能有不同的引用(句柄)值。
上文中的"对象"指的是内核对象,我们在R3中所使用的文件、进程、线程在内核中都有对应内核对象。应用层每次创建或打开进程、文件都会对相应的内核对象创建一个句柄。当多个进程同时打开一个文件时,该文件在内核中只会存在一个文件内核对象,但每个进程都有一个各自的文件句柄,每个句柄会增加内核对象的引用计数,只有当内核对象的引用计数为0时,内核对象才会释放。
私有句柄表
eprocess
指向一个ObjectTable
,ObjectTbale
中存在TableCode
,这个指向的是这个进程的私有句柄表。同时ObjectTable
中还有一个HandleTableList
,这个是一个链表,通过HandleTableList
成员遍历得到所有进程的ObjectTable
地址
我们的目标是获取到_object_header
结构体,这个结构体才是句柄的真正内容。但是不同版本系统下的取法不太一样,win7是直接指向句柄,win10则需要做一些偏移,这些偏移google没有资料,大多都是通过IDA静态分析函数才能得到。
win中有一些根据_handle_table_entry获取进程句柄的函数,我这里没有做过多分析,直接使用前辈分析后的经验。分析目标ntoskrnl.exe
下的ObpEnumFindHandleProcedure
函数,可以看到如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
|
可以在开头部分看到(handle_table_entry->LowValue >> 16) & 0xFFFFFFFFFFFFFFF0ui64)
,这样才能获取到句柄内容,获取到_object_header
.
但是我自己尝试的时候没有获取到,直到我注意到帖子里面最后得到的值开头都是0xffff
,这说明右移前面不是补充0,而是补充1
所以地址计算实际是:(handle_table_entry->LowValue >> 16) & 0xFFFFFFFFFFFFFFF0ui64) + 0xffff000000000000
得到的就是_OBJECT_HEADER
,这表示一个句柄头,句柄体在body的位置,我系统版本的偏移是0x30,进程句柄的话就是_eprocess
结构体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
|
0x18位置的TypeIndex表示这个句柄对应的对象是一个什么类型的对象,比如文件、进程、线程等,0x30的Body就是便指向了该句柄对应的对象结构。若句柄对应的对象是一个进程对象那么0x30的位置存的就是对应进程对象的_EPROCESS
的结构,可以从这个结构便获得进程名、进程ID等等信息。
所以在内核中有一个链表存放了每一个进程的私有句柄表。
TableCode句柄表
句柄表是以页为单位,两层句柄表则是第一层存放第二层的指针,一般只有系统进程才会打开那么多的句柄,恶意进程通常只有一层。win10的机器上tablecode是存放了一页的handle_table_entry,每一个16字节,一页大小是4k,所以一页最多256个句柄。(32位系统的是一页512个句柄)
怎么判断句柄表有几层?
TableCode的最后2个bit表示层数(有的文章说是3个bit,我也不确定),但是目前我看最多的也只有两层,下面分别是0层和1层的情况。
可以看到tablecode句柄表的内容不是句柄,而是_handle_table_entry
,这不是一个结构体,是一个union
从handle_table_entry到句柄还需要一些额外的计算变化,同时一个进程句柄的权限就标注在每个句柄对应的这个结构体当中
Windbg 调试
以手动的方式从一个eprocess内存看到他下面的句柄表
windbg以内核附加模式连接上虚拟机/真机后,首先我们需要一个EPROCESS结构体地址,使用!process 0 0
查看所有进程的基本信息
1 2 3 4 5 6 7 8 9 10 11 |
|
PROCESS的值就是EPROCESS的地址值, 我这里选用csrss.exe的PROCESS值ffffbf8eab1ea080
定位ObjectTable
的值
1 2 |
|
再进一步查看_HANDLE_TABLE结构体,定位TableCode的值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
得到TableCode的值是0xffffd00b`16d58001,注意TableCode最后的一位是1,这表示有两层页表,第一层的值是指向的第二层的指针,所以先查看第一层句柄指针表
1 2 3 4 5 6 7 8 9 |
|
看到有3个二层句柄表,我们选用第一张表
1 2 3 4 5 6 7 8 9 |
|
可以看到从ffffd00b`16667010开始每16字节表示一个_handle_table_entry union体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
(handle_table_entry->LowValue >> 16) & 0xFFFFFFFFFFFFFFF0ui64)
获取_object_header结构体,记得前面填充的要是1,计算一下值
1 2 3 |
|
0xbf8eaaee9430ffff
是ffffd00b16667010
的值,对应的就是handle_table_entry->LowValue
,得到地址0xffffbf8eaaee9430
也就是_object_header
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
可以看到整个object_header的内容,这只是句柄的头部,句柄的内容还在0x30偏移的位置,由于我调试发现这一个进程句柄,所以直接用eprocess展示这个句柄内容。
1 2 3 4 5 6 7 8 9 |
|
这样就通过进程的私有句柄表获得了被打开句柄进程的信息。
判断句柄类型
怎么样从一个句柄头(_object_header)判断出这是一个进程句柄(process handle),文件句柄(file handle)还是设备句柄(device handle)
这个不同版本的系统判断方法不一样,win7/8/8.1是一样的 win10则不同。网络上大多都是win7, win8的我在这篇外网文章上才找到win10的判断方法
win7/8/8.1
这几个版本的_object_header结构大致如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
一般直接TypeIndex表示这个句柄的类型index。相同的类型这个值会相同,还可以进一步查看这个index在nt!object_type
这个表中的具体信息
上面就是Typeindex = 7
对应的意义,是进程句柄
win10
win10的Typeindex
就不一样了,测试会发现,哪怕都是进程句柄这个Typeindex
的值也会不同。
需要将3个单字节的值异或起来,Typeindex ^ nt!ObHeaderCookie ^ 地址的第二个字节
最后得到的才是真的Typeindex
句柄降权/提权
一个句柄的权限,表示句柄拥有者对这个句柄的操作权限,权限有以下几种
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
当我们通过OpenProcess
以多种权限申请打开进程时便将多种权限或运算就得到了我们想要的权限值,我们的目的是为了降低句柄拥有者对我们要保护的进程的操作权限,那最简单暴力的方法便是把handle_table_entry->GrantedAccessBits
的值修改成我们设定的值,直接让句柄拥有者对我们的进程操作权限被修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
代码实现防止CE读取进程内存
现在假设一个场景,我们打开一个记事本(notepad.exe),然后用CE去读取这个进程的内存。我们的目标是保护这个记事本进程,让降低CE中已经打开的记事本进程句柄权限,让CE无法再继续读取内存。
首先CE打开目标进程
我这里直接写死进程号了,使用PsLookupProcessByProcessId
获取指定PID的eprocess
结构体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
然后开始写ProtectProcessHandleByEprocess
函数,这个函数才是主要的逻辑,传入指定eprocess
,然后遍历链表所有的句柄表,匹配是否相同,如果相同则修改权限。
我首先定义两个结构体,方便后面编程
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
然后给这两个结构体定义了几个方法
CheckHandleTableEntry
:检查HANDLE_TABLE_ENTRY的值是否合法NewProcessHandleObject
:新建一个PROCESS_HANDLE_OBJECT结构体FreeProcessHandleObject
:释放一个PROCESS_HANDLE_OBJECT结构体HandleEntryTable2ObjectHeader
: 计算单个handle_table_entry转化成object_header地址
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
|
然后看一下ProtectProcessHandleByEprocess函数,传入eprocess地址后,首先计算出来_object_table地址,然后计算出来HandleTableList地址
1 2 3 4 |
|
然后遍历链表,我们把链表上每一个节点都创建一个PROCESS_HANDLE_OBJECT结构体,因为链表上每一个节点代表一个进程,每一个进程都有一张或者多张句柄表,我们先将链表上每一个节点的部分信息收集好后放在一个数组中,方便我们后续遍历操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
|
然后开始遍历这个数组,具体每一个进程都遍历它的句柄表再来对比,关键逻辑在如下的FilterObjByEprocess
函数中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
|
下面看我们怎么遍历一个进程节点的句柄表,也就是FilterObjByEprocess
函数。
这里我只考虑一层和两次的句柄表,至于三层的不考虑。大部分恶意软件都只有一层句柄表,CE有两层。所以我们分两种来处理,一种是只有一层句柄表的,一种是两层句柄表的
FilterOneTableByEprocess
FilterTWOTabelByEprocess
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
FilterOneTableByEprocess
需要传入两个参数,一个是需要保护的eprocess地址,一个是一层的句柄表tablecode,大致流程如下
检查传入的tablecode有没有异常,有异常的跳过
tablecode其实就是_handle_table_entry数组,所以把所有_handle_table_entry转换成对应的_object_header
然后提取每个_object_header的body对比是否等于我们的目标EPROCESS,如果等于表示找到了,就修改权限
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
|
修改权限也很简单,直接去掉内存读和内存写的权限
1 2 3 4 5 6 7 8 9 |
|
上面就是一层句柄的修改了,这样改完CE还是能读取内存,因为CE是两层句柄表,所以要再处理一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
这样整个代码逻辑就完全了,下面放一下全部的代码,分两个文件,写的比较难看。
- main.c
- header.c
main.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
header.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 |
|
结果
运行后,打印内容大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
然后此时CE已经不能查看内存了
但是这只是当前这个CE进程的,如果关掉这个CE进程,再打开新的CE并且Open记事本进程就又可以重新读取了。因为在新的CE进程中,还没有修改句柄表中记事本进程的权限,所以还需要对抗。
对抗句柄降权思路
反复修改权限
防护方是通过驱动来遍历私有句柄表,然后修改攻击者进程的私有句柄表中指向被保护进程的句柄属性。
那我们也可以写一个驱动来不停的修改我们自身进程的私有句柄权限,将句柄权限改成full control
断链
防护方是通过遍历私有句柄链表来查找攻击方的私有句柄表,那我们可以将私有句柄从链表上断掉,放置一个空/假的私有句柄表结构体或者直接让我们的进程私有句柄表从链表上断开
ObRegisterCallbacks 保护
实际上上述的句柄降权/提权都是针对callbacks保护来做的,很多厂商是使用这个微软公开的API来保护自身进程句柄的权限.
ObRegisterCallbacks 例程为线程、进程和桌面句柄操作注册一系列回调例程。也就是我们可以给我们进程句柄设定一个回调函数,当我们的进程句柄被NtOpenProcess打开后,就会执行这个回调函数。如果我们在回调函数中修改这个句柄的权限,那么任何进程获取我们进程句柄将得到修改过后权限的句柄。
1 2 3 4 |
|
1 |
|
指向 OB_CALLBACK_REGISTRATION 结构的指针,该结构指定回调例程和其他注册信息的列表。
1 |
|
指向变量的指针,该变量接收标识注册的回调例程集的值。 调用方将此值传递给 ObUnRegisterCallbacks 例程,以取消注册回调集。
我们看一下OB_CALLBACK_REGISTRATION结构体
1 2 3 4 5 6 7 |
|
其中OperationRegistration
参数是指向OB_OPERATION_REGISTRATION
结构的数组的指针。 每个结构指定 ObjectPreCallback 和 ObjectPostCallback 回调例程以及调用例程的操作类型。
ObjectPreCallback就是发生进程或线程句柄操作时,操作系统会调用 ObjectPreCallback 例程
ObjectPostCallback就是发生进程或线程句柄操作后,操作系统会调用 ObjectPostCallback 例程
所以我们的操作函数也就是放在这两个数组当中。
ObRegisterCallbacks 例程使用此结构。 此例程的 CallBackRegistration 参数是指向包含 OB_CALLBACK_REGISTRATION 结构的缓冲区的指针,该结构后跟一个或多个 OB_OPERATION_REGISTRATION 结构的数组。
在传递给 ObRegisterCallback 的每个OB_OPERATION_REGISTRATION结构中,调用方必须提供一个或两个回调例程。 如果此结构的 PreOperation 和 PostOperation 成员均为 NULL,则回调注册操作将失败。
为了保证注册成功,我们可以在不用的操作上注册一个空的函数。比如我要注册Pre的,我也写一个空的Post