原创 relaySec Relay学安全
微信号 gh_8d57319ec39c
功能介绍 这是一个纯分享技术的公众号,只想做安全圈的一股清流,不会发任何广告,不会接受任何广告,只会分享纯技术文章,欢迎各行各业的小伙伴关注。让我们一起提升技术。
__发表于
收录于合集
// 逆向学习9.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。//
#include <iostream>void Function(int x) { switch (x) { case 1: printf("1"); break; case 2: printf("2"); break; case 3: printf("3"); break; default: printf("default"); }}int main(){ Function(1);}
反汇编:
我们会发现首先将参数的值给到了eax寄存器中,然后将eax寄存器中的值给到ebp-C4的位置,这个位置是局部变量的位置,然后进行判断如果1等于1的话,那么就跳转到18180A的位置,如果不等于那么继续接着判断2和3,这里我们会发现和if是没有任何区别的。
我们再来看如下C代码:
// 逆向学习9.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。//
#include <iostream>void Function(int x) { switch (x) { case 1: printf("1"); break; case 2: printf("2"); break; case 3: printf("3"); break; case 4: printf("3"); break; default: printf("5"); }}int main(){ Function(1);}
反汇编:
我们发现汇编代码截然不同的,首先他会将我们传递进来的参数给了eax寄存器,然后将eax寄存器中的值赋值给局部变量,然后将局部变量(ebp-C4)中的值给了ecx寄存器,然后使用sub指令将ecx的值减1,然后将ecx的值(0)给了ebp-C4。
然后进行判断如果0大于3的话那么就跳转到D8183F这个位置,这个位置其实就是我们的defalut哪里。
由于0小于3不跳转,所以继续往下走,这里首先会将局部变量中的值给了edx,此时edx的值为0,然后jmp跳转到的位置是通过 edx*4 + 0FB1860h,这个加起来的地址中存储的值就是jmp跳转的地址。
我们可以来看一下这个地址:
我们会发现这个地址其实就是00 fb 18 03,那么也就是说最后jmp跳转的地址就是他。
我们发现正好跳转到了打印1的这个位置,因为我们传递进去的参数就是1。
其实switch在反汇编中会给我们生成一个大表,方便我们进行查询。
那么什么时候才会生成这个表呢?
1.分支少于4的时候用switch没有意义,因为编译器会生成类似if...else之类的反汇编。
2.case后面的常量可以是无序的,并不影响大表的生成。
测试从101开始进行判断查看反汇编:
#include <iostream>void Function(int x) { switch (x) { case 101: printf("101"); break; case 102: printf("102"); break; case 103: printf("103"); break; case 104: printf("104"); break; default: printf("5"); }}int main(){ Function(101);}
反汇编:
我们发现这里将ecx减了65,16进制的65,十进制就是101,我们传递进来的参数就是101,但是被转换成16进制了所以是65,那么这里减掉65之后,最终和3进行比较如果大于3的话,那么就跳转,如果不大于3的话,那么就将局部变量给到edx寄存器,然后jmp进行跳转,跳转的地址其实就是00 3d 18 03。
那么如果不是连续的case呢?这里将两个分支干掉,看看汇编如何生成的。
如下C代码:
// 逆向学习9.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。//
#include <iostream>
void Function(int x) { switch (x) { case 101: printf("101"); break; //case 102: // printf("102"); // break; //case 103: // printf("103"); // break; case 104: printf("104"); break; case 105: printf("105"); break; case 106: printf("106"); break; case 107: printf("107"); break; case 108: printf("108"); break; case 109: printf("109"); break;
default: printf("5"); }}int main(){ Function(101);}
反汇编:我们可以看到在大表中有10个分支,其中两个分支的地址是一样的,其实这两个都是我们删除的那个地址,这个地址对应的其实就是defalut分支的地址。
那么比如说我们有4个case分支如下C代码:
#include <iostream>
void Function(int x) { switch (x) { case 101: printf("101"); break; //case 102: // printf("102"); // break; //case 103: // printf("103"); // break; case 107: printf("107"); break; case 108: printf("108"); break; case 109: printf("109"); break;
default: printf("5"); }}int main(){ Function(108);}
char* a;short* b;int* c;
赋值:
void Test(){ short* b; int* c; a = (char*)10; b = (short*)20; c = (short*)30;}int main(){ Test();}
我们都知道局部变量无论是char也是short都是以4个字节进行存储的,但是使用的时候只使用其对应的字节。
例如如下:
void Test(){ char a; short b; int c; a = 10; b = 20; c = 30;}int main(){ Test();}
可以看到提升了堆栈E4个字节,在使用这些类型的时候都是以自身类型来进行使用的。
那么如果是指针类型呢?
#include <iostream>
void Test() { char* a; short* b; int* c; a = (char*)10; b = (short*)20; c = (int*)30;}int main() { Test();}
可以看到堆栈提升了E4个字节,与普通类型不同的是在使用的时候都是以4个字节也就是dword。
小总结:
1.带 * 类型的变量赋值时只能使用 "完整写法"。
2.带 * 类型的变量宽度永远是4字节,无论类型是什么,无论有一个 *。
#include <iostream>
void Test() { char* a; short* b; int* c; a = (char*)100; b = (short*)100; c = (int*)100; a++; b++; c++;}int main() { Test();}
可以发现结果是101 102 103。
这是如何计算的呢?
例如如下代码:
#include <iostream>
void Test() { char** a; short** b; int** c; a = (char**)100; b = (short**)100; c = (int**)100; a++; b++; c++; printf("%d %d %d", a, b, c);}int main() { Test();}
其实结果是104 104 104
它是有一个公式的的,就是每次去加的时候都会砍掉一个*,比如说char**砍掉一个星就变成了char*,char*占用4个字节,所以加4,那么int 和 short砍掉一个星之后都是占用4个字节所有都加4。
那么我们再来看一个例子:
#include <iostream>
void Test() {
char*** a;
short*** b;
int*** c;
a = (char***)100;
b = (short***)100;
c = (int***)100;
a++;
b++;
c++;
printf("%d %d %d", a, b, c);
}
int main() {
Test();
}
这里加了3个星,那么砍掉一个星之后就变成了2个星了,只要有一个星在,类型就是4个字节,跟后面的星没什么关系,所以最后的结果还是104 104 104。
如下C代码:
#include <iostream>
void Test() {
char* a;
short* b;
int* c;
a = (char*)100;
b = (short*)100;
c = (int*)100;
a = a + 5;
b = b + 5;
c = c + 5;
printf("%d %d %d", a, b, c);
}
int main() {
Test();
}
这里的加法其实跟上面的 ++ --是一样的。
例如a + 5,那么砍掉一个星之后就变成了char类型,而char类型占用一个字节,那么就使用1 乘以 5,最后得到的数就是需要加的。
int也是一样的,4 乘以5 ,最后的结果就变成了120。
所以这三个的值是: 105 110 120
那么其实减法也是一样的。
小总结:
1.带 * 类型的变量可以加,减一个整数,但是不能乘或者除。
2.带*类型变化与其他整数相加或者相减时:
带 * 类型变量 + N = 带 * 类型变量 + N*(去掉一个*后面类型的宽度)
带 类型变量 - N = 带 * 类型变量 - N * (去掉一个后类型的宽度)
这个符号是取地址的符号,他可以取出来某个变量的地址,但是不能应用于常量上面。
#include <iostream>
void Test() {
char a = 10;
short b = 20;
int c = 30;
// & 可以取任何一个变量的地址
//&a 得到的类型就是a的类型 + *
//&b short*
//&c int*
char* pa;
pa = &a;
}
int main() {
Test();
}
反汇编:
可以看到这里是通过lea指令进行的取地址。
那么如果是如下变量的话如何取地址。其实就是在原本的基础上再加一个*。
char****** c = (char******)10;
char******* pa = &c;
符号只能应用于带类型的,比如int* char* 这一类型的,他和&符号相反,&符号是加一个星就是他的类型,而
*符号是砍掉一个星就是他的类型。*是取变量中存储的值。
如下C代码:
void Test(){
int x = 10;
int* px = &x;
int x2 = *px; //*符号只能应用于
}
int main(){
Test();
}
反汇编:
可以看到这里将px这个地址存储到了eax寄存器中,然后把eax寄存器中地址所对应的值存储到ecx里面,最后再存储到局部变量中。
作业-使用for循环查找二进制中符号要求的数字。
#include <iostream>
char data[100] = {
0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
};
void FindBloodAddr() {
char* p;
p = &data[0]; //此时p里面存储的就是数组的首地址。
for (int i = 0; i < 100; i++) {
printf("%x\n", *(p + i)); //当p+1的时候那么就到下一个字节了。
}
}
int main()
{
FindBloodAddr();
}
如上是使用char指针,紧接着我们使用short类型的指针。
#include <iostream>
char data[100] = {
0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
};
void FindBloodAddr() {
short* pa;
pa = (short*) & data[0];
for (int i = 0; i < 50; i++) {
printf("%x\n", *(pa + i));
}
}
int main()
{
FindBloodAddr();
}
int类型指针遍历:
#include <iostream>
char data[100] = {
0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x07,0x09,
0x00,0x20,0x10,0x03,0x03,0x0C,0x00,0x00,0x44,0x00,
0x00,0x33,0x00,0x47,0x0C,0x0E,0x00,0x0D,0x00,0x11,
0x00,0x00,0x00,0x02,0x64,0x00,0x00,0x00,0xAA,0x00,
0x00,0x00,0x64,0x10,0x00,0x00,0x00,0x00,0x00,0x00,
0x00,0x00,0x02,0x00,0x74,0x0F,0x41,0x00,0x00,0x00,
0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x0A,0x00,
0x00,0x02,0x74,0x0F,0x41,0x00,0x06,0x08,0x00,0x00,
0x00,0x00,0x00,0x64,0x00,0x0F,0x00,0x00,0x0D,0x00,
0x00,0x00,0x23,0x00,0x00,0x64,0x00,0x00,0x64,0x00
};
void FindBloodAddr() {
int* pa;
pa = (int*) & data[0];
for (int i = 0; i < 25; i++) {
printf("%x\n", *(pa + i));
}
}
int main()
{
FindBloodAddr();
}
如下C代码:
#include <iostream>
void Function() {
char* x = (char*)"china"; //x中存储的是字符串的首地址
char x1[] = "china";
*(x + 1) = 'A';
}
int main()
{
Function();
}
当我们想要修改指针所指向的字符串的时候会发生错误,会提示写入访问权限冲突,这是因为x这个变量中存储的是china这个字符串的首地址,而真正字符串是存储到了常量区中,在常量区中是只读不能改的。
反汇编:
可以看到这里将地址给了x这个局部变量。
那么我们来看一下使用数组方式存储的字符串是如何操作的。
C代码:
void Function() {
//char* x = (char*)"china"; //x中存储的是字符串的首地址
char x1[] = "china";
/**(x + 1) = 'A';*/
}
int main()
{
Function();
}
反汇编:
可以看到这里首先将字符串china的地址存储到eax寄存器,然后从eax寄存器中存储的地址所对应的值取出给了局部变量x1,然后将这个地址中剩下的值也取出给了cx寄存器,最后将cx也存储到ebp-8的位置。
指针数组顾名思义就是一个数组,只是这个数组的类型是指针类型的。
比如:
int* arr[6] = {0};
这里其实没什么其他区别。
如下实例:这里其实就是将abcdf这些值的地址存储到了int类型的指针数组中了,需要注意的是这里存储的是地址而不是数值,这里的数值是常量,可读不可写,也就是不能改的。
#include <iostream>
int main()
{
int a = 10;
int b = 20;
int c = 30;
int d = 40;
int f = 50;
int* arr[5] = { 0 };
arr[0] = &a;
arr[1] = &b;
arr[2] = &c;
arr[3] = &d;
arr[4] = &f;
}
定义:
#include <iostream>
struct Arg {
int a;
int b;
int c;
};
int main()
{
Arg* pArg; //占用4个字节
pArg = (Arg*)100;
pArg = pArg + 5;
printf("%d\n", pArg);
return 0;
}
#include <iostream>
struct Arg {
int a;
char b;
short c;
};
int main()
{
int x = 10;
Arg* parg = (Arg*) & x;
printf("%d %d %d\n", parg->a, parg->b, parg->c);
return 0;
}
#include <iostream>
int main()
{
int(*px)[5]; //*px int[5]
//宽度:4 宽度 ++ -- +正数 -整数
printf("%d\n", sizeof(px));
//赋值:
px = (int(*)[5])10;
//+整数
px = px + 3; //砍掉一个星 就变成了int [5] 占用20个字节,那么加3的话就是60。
printf("%d\n", px);
return 0;
}
为什么要使用位运算?
1.加密解密
2.内存角度
算数左移:
如下汇编指令:这里表示的意思就是将81放到al8位寄存器中,然后将81转换成二进制就变成了 1000 0001,那么SAL指令的意思就是将1000 0001左移1位,那么就变成了1000 0010 ,左移一位之后,然后将最高位,也就是1,存储到CF标志位中,然后最高位就变成了0,所以最终的值是 0000 0010,转换成16进制就是0x02
mov al,0x81SAL al,1
如下汇编指令:
首先0x81转换成二进制就是1000 0001 那么右移1位的话,其实就是最后面的整个1放到了CF标志位,最后就变成了1100 0000了,转换成十六进制就是C0。
mov al,0x81SAR al,1
其实逻辑左移指令是和计算左移指令是一样的,都是高位进CF标志位。
mov al,0x81SHL al,1
逻辑右移是没有符号的概念的,所以0x81转换二进制 1000 0001 那么右移一位的话就变成了 0100 0000 。
转换十六进制就是0x40。
SHR al,1
循环左移会将最高位的值给到CF标志位,然后会将最高位的值移动到最低位,就比如说1000 0001 ,循环左移一位就是0000 0011,那么转换成十六进制就是3,而CF标志位为1。
rol al,1
循环右移和左移是相反的,比如1000 0001 右移一位就变成了1100 0000,他会将最低位给CF标志位。
ror al,1
比如1000 0001,那么左移一位的话就变成了0000 0010,这里其实就是将CF中的值拿出来然后补到低位。
RCL al,1
#include <iostream>int main(){ char x = 2; char y = 3; printf("%x\n", x & y);与 // 0010 0011 0010 //2 printf("%d\n", x | y);或 // 0010 0011 0011 //3 printf("%x\n", ~x); 非// 0010 1101 // D printf("%d\n", x^y);异或 //0010 0011 //0000 0001 //1 return 0;}
#define TRUE 1;
#define FALSE 0;
#define PI 3.1415926;
#define MAX(A,B)((A) > (B) ?(A):(B))
int Function() {
int x = MAX(2, 3);
}
注意:
1.宏名标识符与左圆括号之间不允许有空白符,应紧接在一起。
2.宏与函数的区别:函数分配额外的堆栈空间,而宏只替换。
3.为了避免出错,宏定义的时候需要给参数加上括号。
4.末尾不需要分号。
5.define可以替代多行的代码,记得后面加 \。
#include <iostream>#include "Test.h"#define TRUE 1;#define FALSE 0;#define PI 3.1415926;#define MAX(A,B)((A) > (B) ?(A):(B))
void Function1() { int* ptr; ptr = (int*)malloc(sizeof(int)*128); //申请一块4 * 128字节的内存 申请的内存在堆中。 if (ptr == NULL) { printf("申请内存失败"); } //初始化分配的内存空间 memset(ptr,0,sizeof(int)*128);
//使用 *(ptr) = 1; //使用完毕 释放申请的堆空间 free(ptr);
//将指针设置为Null ptr = NULL;
}int Function() { int x = MAX(2, 3); return TRUE;}int main(){
Function1();}//申请内存
//判断是否申请内存成功
//初始化内存空间
//使用内存
//释放申请的内存空间
//将指针所指向的内存设置为null
将记事本文件读取到内存中,然后申请出一个缓冲区空间存储。
// stack.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。//#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <malloc.h>
void fun();int Size(FILE*);int main(int argc, char* argv[]){ fun(); return 0;}
//此函数返回文件的大小int Size(FILE* p) {
int num = 0; fseek(p, 0, SEEK_END); num = ftell(p); //再把指针移到开头 fseek(p, 0, SEEK_SET); return num;}void fun() { //定义一个File类型的指针 FILE* fp; //读取文件 fp = fopen("C:\\Windows\\System32\\notepad.exe", "rb"); //判断文件是否读取成功 if (fp == NULL) { printf("文件读取失败"); } //通过Size函数拿到文件的大小 printf("文件大小为:%d\n", Size(fp)); //申请一块缓冲区 char* ch; ch = (char*)malloc(Size(fp)); if (ch == NULL) {
printf("分配空间失败");
} //申请完成缓冲区之后,将文件读取出来的内存写入到缓冲区中。 fread(ch, Size(fp), 1, fp); int address = (int)ch; printf("内存空间地址为:%d", address); free(ch); fclose(fp);
}
复制到另一个文件中尝试打开:
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <malloc.h>
int Size(FILE* p) {
int num = 0; fseek(p, 0, SEEK_END); num = ftell(p); //再把指针移到开头 fseek(p, 0, SEEK_SET); return num;
}void fun() { FILE* fp1; FILE* fp2; int FileSize = 0; //打开文件 fp1 = fopen("C:\\Windows\\System32\\notepad.exe", "rb"); if (fp1 == NULL) { printf("文件读取失败"); } //创建写入的文件 fp2 = fopen("C:\\Users\\Admin\\Desktop\\test.exe", "wb"); //获取第一个第一个文件的大小 FileSize = Size(fp1); //创建一个缓冲区空间 char* ch; ch = (char*)malloc(FileSize); if (ch == NULL) { printf("创建失败"); } fread(ch, FileSize, 1, fp1); fwrite(ch, FileSize, 1, fp2);
free(ch); fclose(fp1); fclose(fp2);
}int main(int argc, char* argv[]){ fun();
return 0;
}
反汇编:
可以看到在没有执行memset初始化内存空间之前里面存储的都是cd cd cd cd这些字符。
那么执行完成之后我们可以看到全部变成了00 00 00 00
紧接着当我们使用过的时候,可以看到值已经写进去了 0a,转换成十进制就是10。
然后当我们释放内存的时候会发现全部变成了dd dd dd dd。
如上就是malloc分配内存空间的反汇编。
PE文件其实就是我们经常看到的EXE文件,SYS文件,DLL文件,他们都有一个共同的特征,无论是EXE还是DLL,他们的前几个字节都是5A4D,这是一个标识。
如下图:EXE
DLL:
PE的结构是分节的,什么叫做分节。
我们可以观察一下EXE文件,可以发现它每过一段数据都会有一堆0,那么我们对于PE这种特征来说叫做分节,每一堆数据都是一节,所以PE结构是分节的。
我们理解为不同的功能,就比如说一个EXE文件有不同的功能,比如说这个功能是可读可写的,但是另一个功能是可读的但是不可写的,但是这不是最主要的。
那么我们会发现在硬盘上它空出来的一堆0比内存中空出来的一堆0是少很多的。
就比如如下图:
这是因为以前一些老的编译器,以前觉得硬盘太少了,所以将内存给拉伸了,就好比那种玩具一样,你拽着头和尾,拉伸那种操作。
但是有一些EXE它在硬盘上和内存上是一样的。
所以说内存和硬盘中间的空隙是由编辑器来决定的。
那么PE分节的原因第一就是为了节省硬盘,那么第二就是为了节省内存。
就比如说我们分了2节,一节的数据是可读的,一节的数据是可读可写的,我们都知道有时候我们登录QQ的时候,不止有一个账户,还可能有其他的小号,这就是多开的问题,那么我多开一个小号的话,既然我已经分节了,那么我直接在分两个节不得了,但是我们要知道我们有一块是只读的,所以说这一块内存我们是不需要在复制一份了,只需要复制可读可写的那一份即可,因为只读的这一份是不会担心被修改的。
如下这张图我们来看一下PE文件的分节:
首先左边是硬盘上的,右边是内存里面的,这些每一个块都是一个节,比如当我们将硬盘上的text块拿到内存中的时候,数据是不会发生改变的,改变的只是对齐方式,也就是我们前面说到的。这些红色的都是用0进行填充的。那么我们这个节在内存中从哪里开始到哪里结束是需要记录的,而块表就是来干这件事情的。
PE文件头和DOS头是对这个EXE做的一个代表性的描述,其实就是标识,就比如说我们这个节拉伸之后有多大,那么将这种数据写入到PE文件头或DOS头。
那么我们现在理解了一个EXE文件从硬盘到内存是一个拉伸的过程,每一个节都存储不同的数据,那么这些数据的描述信息都存储在块表中,也可以叫做节表,那么这个EXE的特征以及标识都在PE文件头和DOS头中存储。
我们先来看DOS头的解析,我们现在只需要找到这些对应的位置即可,不需要知道意思,后面解析的时候会说。
WORD占用2字节。DWORD占用4字节。
struct _IMAGE_DOS_HEADER {0x00 WORD e_magic; //5A4D * 需要背0x02 WORD e_cblp; //00900x04 WORD e_cp; //00300x06 WORD e_crlc;//00000x08 WORD e_cparhdr;//00040x0a WORD e_minalloc; //00000x0c WORD e_maxalloc;//FFFF0x0e WORD e_ss; //00000x10 WORD e_sp;//00B80x12 WORD e_csum;//00000x14 WORD e_ip; //00000x16 WORD e_cs;//00000x18 WORD e_lfarlc; //00400x1a WORD e_ovno; //00000x1c WORD e_res[4]; //00 00 00 00 00 00 00 00 这是两个字节的数组 有4个 所以占用8个字节0x24 WORD e_oemid; //00000x26 WORD e_oeminfo;//00000x28 WORD e_res2[10]; //00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 //这也是两个字节的数组有10个,所以占用20个字节。0x3c DWORD e_lfanew; //000000E0 //DWORD占用4个字节 * 需要背};
如下图对应:
NT头包包含了标准PE头和可选PE头
在找PE头之前我们需要找到PE头的起始位置,还记得我们上面找到的e_lfanew字段吗,里面的值是000000E0,也就是说我们要从开始的位置找E0个字节就是PE起始的位置。
E0转换成十进制是224。
如下图:我们找到了E0的位置,可以发现在我们其实的位置上有一个PE的标志,这里的00004550这四个字节表示的就是PE。
接下来就是解析标准PE头了。
那么我们肯定会有疑问,我们直接从000000E0跳到了00004550这一块,那么中间这一块数据是干什么的,这一块数据是垃圾数据,什么都可以写。
标准PE头解析:
struct _IMAGE_FILE_HEADER {0x00 WORD Machine; //014C 需要背0x02 WORD NumberOfSections; //0009 需要背0x04 DWORD TimeDateStamp; //65 22 15 5D 需要背0x08 DWORD PointerToSymbolTable; //00 00 00 000x0c DWORD NumberOfSymbols; //00 00 00 00 0x10 WORD SizeOfOptionalHeader; //00 E0 需要背0x12 WORD Characteristics; // 01 02 需要背};
可选PE头对应的是:
注意最后一个我们不找之外,其他都需要找到所对饮过的位置。
struct _IMAGE_OPTIONAL_HEADER {0x00 WORD Magic; //01 0B //需要背0x02 BYTE MajorLinkerVersion; //0E0x03 BYTE MinorLinkerVersion; //250x04 DWORD SizeOfCode; //00 00 58 00 //需要背0x08 DWORD SizeOfInitializedData; //00 00 46 00 //需要背0x0c DWORD SizeOfUninitializedData; //00 00 00 00 //需要背0x10 DWORD AddressOfEntryPoint; //00 01 10 23//需要背0x14 DWORD BaseOfCode; //00 00 10 00//需要背0x18 DWORD BaseOfData; //00 10 00 00//需要背0x1c DWORD ImageBase; //00 40 00 00//需要背0x20 DWORD SectionAlignment; //00 00 10 00//需要背0x24 DWORD FileAlignment; //00 00 02 00//需要背0x28 WORD MajorOperatingSystemVersion; //00 060x2a WORD MinorOperatingSystemVersion; //00 000x2c WORD MajorImageVersion; //00 00 0x2e WORD MinorImageVersion; //00 000x30 WORD MajorSubsystemVersion;//00 060x32 WORD MinorSubsystemVersion;//00 000x34 DWORD Win32VersionValue; //00 00 00 00 0x38 DWORD SizeOfImage; //00 02 00 00 //需要背0x3c DWORD SizeOfHeaders; //00 00 04 00//需要背0x40 DWORD CheckSum; //00 00 00 00//需要背0x44 WORD Subsystem; //00 030x46 WORD DllCharacteristics; // 81 400x48 DWORD SizeOfStackReserve; //00 10 00 00//需要背0x4c DWORD SizeOfStackCommit; //00 00 10 00//需要背0x50 DWORD SizeOfHeapReserve; //00 10 00 00 //需要背0x54 DWORD SizeOfHeapCommit;// 00 00 10 00//需要背0x58 DWORD LoaderFlags; //00 00 00 00 0x5c DWORD NumberOfRvaAndSizes; //00 00 00 100x60 _IMAGE_DATA_DIRECTORY DataDirectory[16]; //暂时先不找};
WORD e_magic "MZ标记" 用于判断是否为可执行文件DWORD e_lfanew PE头相对于文件的偏移,用于定位PE文件 通俗一点就是说通过这个字段来找PE是从哪里开始的。就比如说我们前面找到的是E0,那么也就是说PE离我们最开始的位置有E0个字节。
WORD Machine * 程序运行的CPU型号:0x0任何处理器/0x14c 386及后续处理器 (这个值主要是约束这个EXE程序在什么样的CPU上运行)WORD NumberOfSections 文件中存在节的的总数,如果要新增节或者合并节,就要修改这个值。(我们前面说过PE是分节的,每一个数据段都是一节。)
如下图:
除了头之外还有3段数据。
DWORD TimeDateStamp 时间戳,文件的创建时间(和操作系统的创建时间无关),编辑器填写的。DWORD SizeOfOptionaHeader 可选PE头的大小,32位PE文件默认是E0H,而在64位PE文件默认为F0H,大小可以自定义。WORD Characteristics 每个位有不同的意义,可执行的文件值为10F 即0 1 2 3 8位置1.
WORD Magic 说明文件类型,如果是32位下的PE文件它的值是10B,如果是64位下的PE文件他的值是20B(重点)SectionAlignment 内存对齐 1000HFileAlignment 文件对齐 200HDWORD SizeOfCode 所有代码节的和,必须是FileAlignment的整数倍,编辑器填的(这里指的是PE结构分很多节,比如说我其中有一个节中有10字节的代码,那么就是10 * FileAlignment 也就是文件对齐,一般文件对齐的话都是200H,而内存对齐的是话是1000H,所以它存储的值就是1000)DWORD SizeOfInitializedData 已初始化数据大小的和,必须是FileAlignment的整数倍 编辑器填的。DWORD SizeOfUninitializedData 未初始化数据大小的和,必须是FileAlignment的整数倍 编辑器填的。
AddressOfEntryPoint 程序入口点(重点) 程序入口点 + 内存镜像基址 (我们可以发现在使用OD或者xdebg的时候它断点断的那个位置就是程序入口点这个值 + 内存镜像基址)//注意:一个exe文件由多个PE文件组成,比如说dll文件,每一个dll都是一个模块。DWORD BaseOfCode 代码开始的基址 编辑器填的 没用DWORD BaseOfData 数据开始的基址 编译器填的 没用ImageBase 内存镜像DWORD SizeOfImage 内存中整个PE文件的映射的尺寸,可以比实际的值大,但必须是SectionAlignment的整数倍。DWORD SizeOfHeaders 所有头+节表按照文件对齐后的大小,否则加载会出错。DWORD CheckSum 校验和,一些系统文件有要求,用来判断文件是否被修改。DWORD SizeOfStackReserve; 初始化时保留的堆栈大小DWORD SizeOfStackCommit; 初始化时实际提交的大小DWORD SizeOfHeapReserve; 初始化时保留堆的大小DWORD SizeOfHeapCommit; 初始化时实际提交堆的大小DWORD NumberOfRvaAndSizes; 目录项数(比如说存储了一个0x10,那么就表示在他后面还有10个结构。)
如下图最左边的是硬盘上的,中间的是加载到内存中的,加载到内存中的是没有执行的,如果想执行的话就需要拉伸,那么也就是最右边的这张图,首先这里的开头就是imageBase,然后AddressOfEntryPoint其实实在代码区这里,那么也就是说得通过ImageBase
- AddressOfEntryPoint 才能找到程序的入口点,那么整个拉伸的过程就是PE loader。
#define _CRT_SECURE_NO_WARNINGS#include <iostream>#include <malloc.h>#include <malloc.h>#include <windows.h>int Size(FILE* p) { int num = 0; fseek(p, 0, SEEK_END); num = ftell(p); //再把指针移到开头 fseek(p, 0, SEEK_SET); return num;}//这里需要传递进去一个文件void readPeFile() { //首先定义一个文件指针 FILE* fp = NULL; //定义文件的大小 DWORD FileSize = 0; LPVOID pfileBuffer = NULL; //打开文件 fp = fopen("C:\\Windows\\system32\\notepad.exe", "rb"); if (fp == NULL) { printf("打开文件失败"); } //读取文件的大小 FileSize = Size(fp); //printf("文件大小为:%\d", FileSize); //创建缓冲区 pfileBuffer = malloc(FileSize); if (pfileBuffer == NULL) { printf("内存分配失败"); free(pfileBuffer); fclose(fp); } //将exe写入到内存中,动态分配内存 size_t n = fread(pfileBuffer, FileSize, 1, fp); if (n ==NULL) { printf("数据读取失败"); free(pfileBuffer); fclose(fp); } //定义相关的DOS头 NT头 节表相关的信息 PIMAGE_DOS_HEADER pDosHeader = NULL; PIMAGE_NT_HEADERS pNTHeader = NULL; PIMAGE_FILE_HEADER pPEHeader = NULL; PIMAGE_OPTIONAL_HEADER32 pOptionHeader = NULL; PIMAGE_SECTION_HEADER pSectionHeader = NULL; //判断DOS头的mz标志 //这里的PWORD,WORD是2个字节,也就是说取前两个字节也就是MZ。 if ((*(PWORD)pfileBuffer) != IMAGE_DOS_SIGNATURE) { //这里的IMAGE_DOS_SIGNATURE对应的就是0x5a4d printf("不是有效的PE文件"); free(fp); } pDosHeader = (PIMAGE_DOS_HEADER)pfileBuffer;//获取DOS头开始的地址 //打印Dos头信息 printf("******************DOS头*********************\n"); printf("MZ标志: %x\n", pDosHeader->e_magic); printf("PE偏移: %x\n", pDosHeader->e_lfanew); //判断pe有效位 if (*(PWORD)((DWORD)pfileBuffer + pDosHeader->e_lfanew) != IMAGE_NT_SIGNATURE) { printf("不是有效得pe标识\n"); free(pfileBuffer); } pNTHeader = (PIMAGE_NT_HEADERS)((DWORD)pfileBuffer + pDosHeader->e_lfanew); //定位到NT Header //打印NT printf("**************NT头*************\n"); printf("NT : %x\n", pNTHeader->Signature); pPEHeader = pPEHeader = (PIMAGE_FILE_HEADER)(((DWORD)pNTHeader) + 4); //打印标准PE头 printf("**************标准PE头**********\n"); printf("Machine : %x\n", pPEHeader->Machine); printf("NumberOfSections : %x\n", pPEHeader->NumberOfSections); printf(" SizeOfOptionalHeader : %x\n", pPEHeader->SizeOfOptionalHeader); //打印可选pe头 /*printf("********************OPTIOIN_PE头********************\n");
printf("OPTION_PE:%x\n", pOptionHeader->Magic); printf("sizeofcode=%x\n", pOptionHeader->SizeOfCode);
printf("baseofcode=%x\n", pOptionHeader->BaseOfCode);
printf("baseofdata=%x\n", pOptionHeader->BaseOfData);
printf("imagebase=%x\n", pOptionHeader->ImageBase);
printf("sectionalignment=%x\n", pOptionHeader->SectionAlignment);
printf("filealignment=%x\n", pOptionHeader->FileAlignment);
printf("sizeofimage=%x\n", pOptionHeader->SizeOfImage);
printf("sizeofheader=%x\n", pOptionHeader->SizeOfHeaders);
printf("checksum=%x\n", pOptionHeader->CheckSum);*/
pSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD)pOptionHeader + pPEHeader->SizeOfOptionalHeader); //获取节表的地址 printf("%d\n", pSectionHeader);
printf("***********************节表信息如下*************************\n"); DWORD NumberOfSection = pPEHeader->NumberOfSections; for (DWORD i = 0; i < NumberOfSection; i++, pSectionHeader++) { printf("********************第%d个节信息:***********************\n", i + 1); printf("section_name:"); for (DWORD j = 0; j < IMAGE_SIZEOF_SHORT_NAME; j++) { printf("%c", pSectionHeader->Name[j]); } printf("\n"); printf("misc:%x\n", pSectionHeader->Misc); printf("VirtualAddress:%08X\n", pSectionHeader->VirtualAddress); printf("SizeOfRawData:%08X\n", pSectionHeader->SizeOfRawData); printf("PointerToRawData:%08X\n", pSectionHeader->PointerToRawData); printf("Characteristics%08X\n", pSectionHeader->Characteristics); } //printf("%x\n", pDosHeader); //printf("%x", *(PWORD)pfileBuffer); //这里打印出来的就是5a4d //printf("%d\n", *(PWORD)((DWORD)pfileBuffer + pDosHeader->e_lfanew)); //这里是将5a4d加上DOS头的偏移地址 //打印出来的地址就是4550 转换16进制即可 //printf("%d\n", (PIMAGE_FILE_HEADER)(((DWORD)pNTHeader) + 4)); //打印NT头的第一个属性也就是DWORD Signature 也就是标准PE头的地址。 //printf("%d\n", (PIMAGE_OPTIONAL_HEADER32)((DWORD)pPEHeader + IMAGE_SIZEOF_FILE_HEADER));//获取到可选PE头的地址 fclose(fp);} int main(){ readPeFile();}
特点:
1.联合体的成员是共享内存空间的。
2.联合体的内存空间大小是联合体成员中对内存空间大小要求最大的空间大小。
3.联合体最多只有一个成员有效。
#include <iostream>union TestUnion{ char x; int y;};int main(){ TestUnion t; t.y = 0x12345678;
printf("%x\n", t.x);
return 0;}
如下图除了上面头部信息外,下面都是分节的,那么这些节的描述信息都是在节表中,就比如说如果节是书里面每一页的内容的话,那么节表就是目录。
那么我们来找一下节表的位置:
DOS头 + 垃圾数据 + 标准PE头 + 可选PE头的大小(SizeOfOptionaHeader E0字节)
然后再找E0个字节就是节表了,因为SizeOfOptionaHeader的值是E0.
E0的十进制是224个字节。
如下就是可选PE头。
那么我们的节表有几个呢?
可以通过标准PE头中的NumberOfSections属性来看。
可以看到他的值为00 09 表示我们有9个节表。
那么现在就可以开始找节表了。
首先是节表的名字:
如下:可以看到这里的第一个值就是节表的名字,占用8个字节。
#define IMAGE_SIZEOF_SHORT_NAME 8typedef struct _IMAGE_SECTION_HEADER { BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; union { DWORD PhysicalAddress; DWORD VirtualSize; // 节区尺寸 } Misc; DWORD VirtualAddress; // 节区RVA DWORD SizeOfRawData; // 在文件中对齐后的尺寸 DWORD PointerToRawData; // 在文件中的偏移 DWORD PointerToRelocations; // 在OBJ文件中使用 DWORD PointerToLinenumbers; WORD NumberOfRelocations; WORD NumberOfLinenumbers; DWORD Characteristics; // 节区属性字段} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
那么对应名字就是.textbss
第二个成员是双字 文件对齐前的真实尺寸,该值是可以不准确的,占用4个字节。
如下图:比如说文件对齐是200H,那么这是对齐之后的,从A到B这一块是对齐前的真实尺寸,那么从后面的0开始就是为了对齐而填充的,所以第二个成员就是对齐前的真实尺寸。该值是可以不准确的,这里所说的是如果你的这个值是编译器编译的,没有被别人修改的话,就是准确的,如果被别的软件修改过的话那么就是不准确的。
第三个成员是VirtualAddress,VirtualAddress节区是在内存中的偏移地址,加上ImageBase才是内存中的真正地址。
其实指的就是在内存中imagebase到A这里有多远。
第四个成员是SizeOfRawData 节在文件中对齐后的尺寸。
我们前面知道了第二个成员表示的是在文件中没有对齐前的真实尺寸,而对齐后的尺寸就是整个蓝色部分。
第五个成员是PointerToRawData 节区是在文件中的偏移。意思就是说在文件中读取的时候从0开始的,也就是说从0到这个节区的数据有多远,和VirtualAddress不同的是VirtualAddress是在内存中体现的,而PointerToRawData是在文件中体现的。
Characteristics属性是有一个对照表的。
我们可以看到Charactristics属性的值为:60000020
那么如上图对照的话其实就是包含可执行的代码,对应的就是20,那么这个最高位6的话对应表中的其实就是最高位的4和2加起来的,也就是说该块可执行,该块可写。
联系:解析节表结构,并指初始位置以及PointerToRawData和SizeOfRawData属性。
Misc.VirtualalSize和SizeOfRawData谁大?
这是不确定的,因为VirtualalSize中存储的是内存中的size。
如下图来进行解释:
如下图是在文件中的结构,其中有一个SizeOfHeaders属性,这个属性是在可选PE头中的, 它表示了所有头和节表加起来然后文件对齐后的大小 。
这个属性是不确定的,一个文件和另一个文件是不一样的。
那么如上是在文件中的结构,那么如果我们想要将文件copy到内存中的话,这里有一个SizeOfRawData属性,这个属性的话是一个节按照文件对齐以后的大小,PointerToRawData就是从Dos头开始到节这里有多远。
那么Misc.VirtualalSize和SizeOfRawData谁大呢?
这是不确定的,VirtualalSize是在内存中拉伸之后的大小。
那么如何将文件中的copy到内存中呢?
也就是说我们想要将fileBuffer中的数据复制到内存中如何来做呢?
1.首先考虑的是我们应该分多少内存呢?意思就是说我们应该开辟多大的内存来复制文件中的数据。这里可以通过SizeOfImage (内存中整个PE文件的映射的尺寸,可以比实际的值大,但必须是SectionAlignment的整数倍。)2.那么开辟完新的空间之后,然后将文件中的数据一个一个复制到内存中,首先将头部和节表全部copy过来,copy的大小可以根据SizeOfHeaders来决定,比如说在文件中头部和节表加起来是1000,那么copy到内存中也是1000,copy之前需要将内存中间全部初始化为0。3.紧接着就是节的数据了,首先通过PointerToRawData找到节开始的地方,然后开始复制数据,复制到内存的什么地方是由VirtualAddress来决定的,那么到底要copy多少数据是由SizeOfRawData来决定的。
在计算机中都是0和1进行存储的,那么我们的一些CALL或者JMP指令也不例外,只是OD或者dbg为了展示所以给我们转换成了16进制。
这里我们可以理解为CALL转换为硬编码是E8,JMP转换为硬编码是E9。
这里我们写了一个简单的函数调用:
#include <iostream>
void fun() { printf("1");}int main(){ fun();}
我们可以如下反汇编这张图:
我们可以看到在CALL前面有一个E8 44F7FFFF,这个其实就是硬编码,这里的E8表示的就是CALL,那么后面的是需要计算出来的。
计算的公式为:
值 = 真实跳转的地址 - E8的下一条地址
我们来计算一下:
44F7FFFF = 8F10FA - 8F19B6
那么我们来看一下JMP:
Jmp对应的硬编码是E9。
那么我们来算一下JMP后面的硬编码:
jmp E9后面的硬编码 = 真实跳转的地址 - E9的下一条地址
那么也就是:
jmp E9 = 真实跳转的地址 - E9的下一条地址
其实就是添加CALL和JMP这两个的硬编码,那么前面我们可以计算出CALL和JMP的硬编码,所需要的是E8或E9下下一条的地址,那么这个地址我们如何去计算呢?
我们会发现CALL指令的这一条地址加上硬编码这个地址的长度也就是5,就是下一条指令的地址。
那么我们可以来看一下MessageBox函数的反汇编:
#include <iostream>#include <Windows.h>void fun() { printf("1");}int main(){ MessageBox(0, 0, 0, 0); fun();}
可以看到反汇编如下: push的硬编码是6A,后面的00就是值。
所以我们硬编码就可以这样写了:
6A 00 6A 00 6A 00 6A 00 E8 00 00 00 00 E9 00 00 00 00
那么我们添加在哪里呢?
我们需要添加在节中即可。
我们可以看到拉伸前和拉伸后的空间空出来89个字节,完全够用。
首先我们得知SizeOfRawData的值为00250E00 ,这是文件对齐后的尺寸,那么PointerToRawData的值为400,也就是说它是文件头到这块节的偏移地址,那么也就是我们400这里加上00250E00之后就是下一个节的地址。
那么我们往上找一下其实就是代码区了。
那么我们就可以将代码写入到这块空白区了。
那么现在我们就需要算一下跳转的地址了,也就是E8后面的值。
这里的话比如说我们要跳转的地址为77E5425F
那么我们就可以通过公式来进行计算:
首先我们需要加上8个字节,然后再加上5个字节就是E8下一条的地址,这里需要注意的是这里是文件中的,而不是内存中的,如果文件中和内存中的对齐方式不一样的话,那么还需要加上imageBase。
这里需要加上imageBase,可以再petool工具中查看。
可以看到它的值是00400000。
那么将00400000 加上我们上面E8下一条的地址,最后使用真实地址减去即可。
77E5425F - (00400000+25118D) = 778030D2(E8硬编码地址)
那么JMP的话需要跳转到入口的地方。
在EntryPoint这个属性,这个属性的意思就是程序入口,他的值为00204363,需要加上ImageBase,所以就是60 4363,这是真正跳转的地址。
那么它的E9的下一条地址就是
400000+251192 = 651192
然后将604363 - 651192即可
604363 - 651192 = FFFB31D1
然后我们需要将入口的地址也就是OEP的地址改为我们的地址也就是:00251190即可。
这里需要注意的是飞鸽这个程序需要找到EP和OEP相同的,要不然改不成功。
在这之前复习一下前面的代码:
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
#include <malloc.h>
#include <malloc.h>#include <windows.h>int Size(FILE* p) {
int num = 0; fseek(p, 0, SEEK_END); num = ftell(p); //再把指针移到开头 fseek(p, 0, SEEK_SET); return num;
}//读取文件的函数,返回的是文件的大小void readFile() { //首先定义一个文件指针 FILE* fp = NULL; //定义文件的大小 DWORD FileSize = 0; LPVOID pfileBuffer = NULL;
//打开文件 fp = fopen("C:\\Windows\\system32\\notepad.exe", "rb"); if (fp == NULL) { printf("打开文件失败"); } //读取文件的大小 FileSize = Size(fp); //printf("文件大小为:%\d", FileSize); //创建缓冲区 pfileBuffer = malloc(FileSize); if (pfileBuffer == NULL) { printf("内存分配失败"); free(pfileBuffer); fclose(fp); } //将exe写入到内存中,动态分配内存 size_t n = fread(pfileBuffer, FileSize, 1, fp); if (n == NULL) { printf("数据读取失败"); free(pfileBuffer); fclose(fp); }
//FileBuffer --> imageBuffer //首先初始化PE头部的一些信息 PIMAGE_DOS_HEADER pDosHeader = NULL; //DOS头 PIMAGE_NT_HEADERS pNTHeader = NULL; //NT头 PIMAGE_FILE_HEADER pPEHeader = NULL; //标准PE头 PIMAGE_OPTIONAL_HEADER32 pOptionHeader = NULL; //可选PE头 PIMAGE_SECTION_HEADER pSectionHeader = NULL; //节表 //初始化IMAGEBUFFER指针 LPVOID pTempImagebuffer = NULL; if (pfileBuffer == NULL) { printf("读取到内存的pfilebuffer无效!!!"); }
//判断PE独有的标记 MZ标记 if (*((PWORD)pfileBuffer) != IMAGE_DOS_SIGNATURE) { //这里的IMAGE_DOS_SIGNATURE其实就是5a4d printf("这不是一个PE文件"); } pDosHeader = PIMAGE_DOS_HEADER(pfileBuffer);//获取到DOS头 //判断是否有PE的标志 if (*((PDWORD)((DWORD)pfileBuffer + pDosHeader->e_lfanew)) != IMAGE_NT_SIGNATURE) { // 注意指针的加法是:去掉一个*后的类型相加。必须转换为DWORD类型再加减。 //相加后的和 强制类型转换为4字节指针类型(PWORD) IMAGE_NT_SIGNATURE 4BYTES printf("不是有效的PE标志!\n"); } // 强制结构体类型转换 pNTHeader = (PIMAGE_NT_HEADERS)((DWORD)pfileBuffer + pDosHeader->e_lfanew); //获取到NT头 pPEHeader = (PIMAGE_FILE_HEADER)((DWORD)pNTHeader + 4); //获取标准PE头 pOptionHeader = (PIMAGE_OPTIONAL_HEADER32)((DWORD)pPEHeader + IMAGE_SIZEOF_FILE_HEADER); //获取到可选PE头 //这里的IMAGE_SIZEOF_FILE_HEADER其实就是标准PE头的大小 值为20 pSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD)pOptionHeader + pPEHeader->SizeOfOptionalHeader); //获取到节表 这是是通过标准PE头的SizeOfOptionalHeader属性来定位节表 //分配动态内存 pTempImagebuffer = malloc(pOptionHeader->SizeOfImage); //这里的SizeOfImage表示的是内存中整个PE文件的映射的尺寸 if (!pTempImagebuffer) { printf("分配内存失败"); } //初始化动态内存 memset(pTempImagebuffer,0, pOptionHeader->SizeOfImage);
// 拷贝头部 //第一个参数是你要将内容copy到哪里 //第二个参数是你要复制的数据源的指针 //最后一个参数就是你要copy多少字节 memcpy(pTempImagebuffer, pDosHeader, pOptionHeader->SizeOfHeaders);
// 循环拷贝节表 PIMAGE_SECTION_HEADER pTempSectionHeader = pSectionHeader; for (DWORD i = 0; i < pPEHeader->NumberOfSections; i++, pTempSectionHeader++) { memcpy((void*)((DWORD)pTempImagebuffer + pTempSectionHeader->VirtualAddress), (void*)((DWORD)pfileBuffer + pTempSectionHeader->PointerToRawData), pTempSectionHeader->SizeOfRawData); } // 返回数据 pTempImagebuffer = NULL;}int main(){ readFile();}
预览时标签不可点
微信扫一扫
关注该公众号
轻触阅读原文
继续滑动看下一个
知道了
微信扫一扫
使用小程序
取消 允许
取消 允许
: , 。 视频 小程序 赞 ,轻点两下取消赞 在看 ,轻点两下取消在看