文章目录
首先下载运行ConsoleApplication2.exe:
发现是输入一个字符串或者数字,错误就输出“error”.
1、脱UPX壳
然后用PEiD查壳:
发现有UPX的壳,再用Upx静态脱壳机脱壳:
2、IDA反汇编静态分析
接下来用IDA打开脱壳后的ConsoleApplication2.exe,找到main函数:
进入main_0()函数查看:
main_0()函数的伪代码是:
__int64 main_0()
{
int v0; // edx
__int64 v1; // ST04_8
char v3; // [esp+0h] [ebp-160h]
int v4; // [esp+D0h] [ebp-90h]
int j; // [esp+DCh] [ebp-84h]
int i; // [esp+E8h] [ebp-78h]
char Str[104]; // [esp+F4h] [ebp-6Ch]
srand(0xCu);
j_memset(&unk_423D80, 0, 0x9C40u);
for ( i = 1; i <= 20; ++i )
{
for ( j = 1; j <= i; ++j )
dword_41A138[100 * i + j] = rand() % 100000;
}
sub_41134D("input your key with your operation can get the maximum:", v3);
sub_411249("%s", Str);
if ( j_strlen(Str) == 19 )
{
sub_41114F(Str);
v4 = 0;
j = 1;
i = 1;
dword_423D78 += dword_41A138[101];
while ( v4 < 19 )
{
if ( Str[v4] == 76 )
{
dword_423D78 += dword_41A138[100 * ++i + j];
}
else
{
if ( Str[v4] != 82 )
{
sub_41134D("error\n", v3);
system("pause");
goto LABEL_18;
}
dword_423D78 += dword_41A138[100 * ++i + ++j];
}
++v4;
}
sub_41134D("your operation can get %d points\n", dword_423D78);
system("pause");
}
else
{
sub_41134D("error\n", v3);
system("pause");
}
LABEL_18:
HIDWORD(v1) = v0;
LODWORD(v1) = 0;
return v1;
}
程序的逻辑就是先初始化了一个随机数种子srand(0xCu),之后生成了一个特定的数组元素大小在100000以内的伪随机数数组dword_41A138,之后我们要输入一个长度为19的字符串,然后逐个判断字符串的字符是不是L(76)或者R(82),不正确就输出error,最后都正确就输出“your operation can get %d points”。
3、伪随机数组
接下来开始破解,我们首先根据随机数种子srand(0xCu)得到伪随机数数组dword_41A138:
源代码:
#include<stdio.h>
#include <stdlib.h>
int main(){
/// 随机数种子
srand(0xCu);
/// 存放随机数的数组,数组下标最大有100 * 20 + 20
int dword_41A138[2100];
int i , j;
///当前数组下标
int lab = 0;
for ( i = 1; i <= 20; ++i ){
///%2d:对长度为2以内的数字右对齐
printf("%2d ", i );
for ( j = 1; j <= i; ++j ){
lab = 100 * i + j;
dword_41A138[lab] = rand() % 100000;
///%5d:对长度为5以内的数字右对齐
printf("%5d ",dword_41A138[lab]);
}
printf("\n");
}
return 0;
}
运行结果:
结合题目Mountain climbing(爬山),所以这个数组dword_41A138就相当于一座山,而我们输入19个字符‘L’和‘R’就是爬山的方向,从下面的代码可以看出:
if ( Str[v4] == 76 )
{
dword_423D78 += dword_41A138[100 * ++i + j];
}
else
{
if ( Str[v4] != 82 )
{
sub_41134D("error\n", v3);
system("pause");
goto LABEL_18;
}
dword_423D78 += dword_41A138[100 * ++i + ++j];
}
当输入的字符为L=76时数组下标100 * ++i表示往下走(例如从77走到5628),当输入的字符为R=82时数组下标100 * ++i + ++j表示表示往右下角走(例如从77走到6232)。因为输入的是19个字符,所以不管它是到底是L还是R,最终都会从第一行第一个数77走到第20行。
4、第一次错误的思路(没有考虑完整)
结合最后输出的“your operation can get %d points”(你的操作可以获得%d分),我们可以知道这个游戏相当于一个从左上角走到右下角的爬山游戏,走过的路径的每个位置上的数字加起来就是总得分。
由此我们可以逆推出最大的得分和对应的字符串:
源代码:
#include<stdio.h>
#include <stdlib.h>
int main(){
/// 随机数种子
srand(0xCu);
/// 存放随机数的数组,数组下标最大有100 * 20 + 20
int dword_41A138[2100];
int i , j;
///当前数组下标
int lab = 0;
for ( i = 1; i <= 20; ++i ){
///%2d:对长度为2以内的数字右对齐
printf("%2d ", i );
for ( j = 1; j <= i; ++j ){
lab = 100 * i + j;
dword_41A138[lab] = rand() % 100000;
///%5d:对长度为5以内的数字右对齐
printf("%5d ",dword_41A138[lab]);
}
printf("\n");
}
///分数
int dword_423D78 = 0;
///分数的初值默认为第一行的第一个数
dword_423D78 += dword_41A138[101];
///输入的字符串
char Str[19];
i = 1 ;
j = 1 ;
while(i < 20){
printf("%d:", i);
///右下角的值大于下面的值
if(dword_41A138[100 * (i + 1) + (j + 1)] > dword_41A138[100 * (i + 1) + j]){
Str[i-1] = 'R';
printf("%c ",Str[i-1]);
dword_423D78 += dword_41A138[100 * (++i) + (++j)];
}else{
Str[i-1] = 'L';
printf("%c ",Str[i-1]);
dword_423D78 += dword_41A138[100 * (++i) + j];
}
}
Str[19] = '\0';
///444740
printf("\n最终的分数是:%d\n", dword_423D78);
///RRRRRLLRRRLRLRRRLRL
printf("输入的字符串为:%s\n", Str);
system("pause");
return 0;
}
运行结果:
但是把得到的字符串输入到ConsoleApplication2.exe,结果竟然是error:
5、没有考虑的sub_41114F函数
我们再查看源码,发现最开始有个sub_41114F(Str)函数:
开始点进去查看以为它是没有用的,现在逐个点进去查看,这部分的函数调用是sub_41114F函数—>sub_411900函数->sub_4110A5函数->sub_411750函数:
sub_41114F函数:
sub_411900函数:
上面这个函数的参数都是没法反编译的,只能查看汇编代码:
继续查看函数:
sub_4110A5函数:
最终进入sub_411750函数:
其中VirtualQuery和VirtualProtect都是虚拟内存API函数,LPCVOID lpAddress指的就是虚拟内存,int a2 应该是用来控制循环的,int a3的值就是在前面的函数输入的参数是4。
源代码:
BOOL __cdecl sub_411750(LPCVOID lpAddress, int a2, int a3)
{
int v3; // ST1C_4
DWORD flOldProtect; // [esp+D4h] [ebp-2Ch]
struct _MEMORY_BASIC_INFORMATION Buffer; // [esp+E0h] [ebp-20h]
VirtualQuery(lpAddress, &Buffer, 0x1Cu); // 该函数用来查询内存中指定页的特性。参数1指向希望查询的虚拟地址;参数2是指向内存基本信息结构的指针;参数3指定查询的长度。
// 该函数用来把已经分配的页改变成保护页。参数1指定分配页的基地址;参数2指定保护页的长度;参数3指定页的保护属性,取值PAGE_READ、PAGE_WRITE、PAGE_READWRITE等等;参数4用来返回原来的保护属性。
VirtualProtect(Buffer.BaseAddress, Buffer.RegionSize, 0x40u, &Buffer.Protect);
while ( 1 )
{
v3 = a2--; // a2控制循环次数
if ( !v3 )
break;
*(_BYTE *)lpAddress ^= a3; // 虚拟地址上的值 异或 4
lpAddress = (char *)lpAddress + 1; // 虚拟地址+1,到下一个
}
return VirtualProtect(Buffer.BaseAddress, Buffer.RegionSize, Buffer.Protect, &flOldProtect);
}
还是没太看明白这个函数对Str字符串做了什么操作,所以我们接下来结合OD来对它动态调试。
6、OD动态分析
用OD打开ConsoleApplication2.exe:
搜索字符串,找到题目输出的字符串“input your key with your operation can get the maximum:”:
双击字符串跳转到它所在的位置
ConsoleA.00417B30就是字符串input your key with your operation can get the maximum:
ConsoleA.0041134D就是输出函数printf
ConsoleA.00417B74就是 %s
ConsoleA.00411249就是输入函数scanf
这些我们可以在IDA中得到验证。
接下来我们在字符串input your key with your operation can get the maximum:和输入函数scanf后面下断点:
输入字符串:RRRRRRRRRRRRRRRRRRR
我们可以看到输入的(ASCII “RRRRRRRRRRRRRRRRRRR”),保存在堆栈地址=0019FE98上:
我们继续单步调试,发现经过:
00411C4F E8 79F4FFFF call ConsoleA.004110CD
这一行,之后EAX = 0x00000013,正好是10进制的19:
然后下面的一行:
00411C57 83F8 13 cmp eax,0x13
将eax的值和0x13比较大小。结合之前反编译出来的源码:
if ( j_strlen(Str) == 19 )
我们可以知道ConsoleA.004110CD就是j_strlen函数。
接下来继续单步运行,因为我们输入的字符串长度是19,所以能够通过判断,继续跳转运行:
之后我们在
00411C8B E8 BFF4FFFF call ConsoleA.0041114F
后面一行下个断点
运行到这一行,我们就可以知道ConsoleA.0041114F函数对Str字符串产生了什么影响了:
我们可以看到RRRRRRRRRRRRRRRRRRR经过了ConsoleA.0041114F函数之后变成了RVRVRVRVRVRVRVRVRVR,字符串Str的偶数位上的字符都从R变成了V,这就是ConsoleA.0041114F函数的作用。
7、得到真正的flag
我们再重新运行,把输入改成RRRRRLLRRRLRLRRRLRL:
运行到刚刚ConsoleA.0041114F函数之后的断点位置:
我们可以看到RRRRRLLRRRLRLRRRLRL变成了RVRVRHLVRVLVLVRVLVL,就是把字符串Str的偶数位置上的R变成V、L变成H,结合之前IDAfan编译出来的0041114F函数的伪代码,我们可以知道这应该就是*(_BYTE *)lpAddress ^= a3; // 虚拟地址上的值 异或 4
的结果,其中V = R ^ 4 ;H = L ^ 4 。
其实,到这里我们就已经可以知道正确的输入字符串就是RVRVRHLVRVLVLVRVLVL:
即flag为:zsctf{RVRVRHLVRVLVLVRVLVL}。
8、继续的仔细分析(sub_411750函数)
我们再继续分析一下最内层的sub_411750函数,可以直接再OD中按快捷键Ctrl + G地址跟随,输入411750就可以跟随到sub_411750函数了:
接下来我们继续单步运行,可以看到VirtualQuery和VirtualProtect函数并不会改变Str字符串的值:
但是,我们发现运行完sub_411750函数这部分的代码,Str字符串的值并没有改变:
也就是我们刚刚分析的由sub_411750函数来改变Str字符串的值是错误的!!!
可是运行完sub_41114F函数之后Str字符串的值是被改变了的,调用流程:sub_41114F函数—>sub_411900函数->sub_4110A5函数->sub_411750函数,所以真正改变Str字符串的函数,不是sub_411750函数,而是sub_411900函数或者sub_4110A5函数。
继续单步运行,从sub_411750函数返回到sub_4110A5函数,发现sub_4110A5函数只是一个简单的调用,不可能改变Str字符串的值:
9、关键的sub_411900函数
接着单步运行,从sub_4110A5函数返回到sub_411900函数:
在返回sub_411900函数后,
1、先把堆栈 ss:[0019FD54]初始化为0用来控制循环的跳转,之后每次循环加1,当它大于等于19时跳出循环;
2、然后eax=ss:[0019FD54]大小范围是018,接着让eax与1,偶数与1后eax为0,奇数与1后eax为1,这样就可以区分开018中的奇数和偶数;
3、当eax为0、2、……等偶数时,这两条汇编
test eax,eax
je short 00411992
会跳过下面的改变字符的代码,当eax为1、3、……等奇数时,这两条汇编不跳转,继续运行下面的改变字符的代码,
4、之后继续运行,把字符串"RVRVRHLVRVLVLVRVLVL"首地址堆栈 ss:[0019FDA0]=0019FE98赋值给eax,之后eax+1获得第二个字符‘V’的地址,再地址上的‘V’取出来给ecx,之后ecx异或4,把异或后的结果赋值给原来字符串的对应位置;
通过这部分的代码实现了改变字符串RVRVRHLVRVLVLVRVLVL到
加上注释的汇编代码如下:
00411953 C745 BC 0000000>mov dword ptr ss:[ebp-0x44],0x0 ; 先把堆栈 ss:[0019FD54]初始化为0
0041195A EB 09 jmp short ConsoleA.00411965 ; 跳转到411965
0041195C 8B45 BC mov eax,dword ptr ss:[ebp-0x44] ; eax = 堆栈 ss:[0019FD54]
0041195F 83C0 01 add eax,0x1 ; eax + 1
00411962 8945 BC mov dword ptr ss:[ebp-0x44],eax ; 堆栈 ss:[0019FD54] = eax
00411965 837D BC 13 cmp dword ptr ss:[ebp-0x44],0x13 ; 比较堆栈 ss:[0019FD54],19
00411969 7D 29 jge short ConsoleA.00411994 ; JGE: 大于或等于转移指令,跳出循环
0041196B 8B45 BC mov eax,dword ptr ss:[ebp-0x44] ; eax = 堆栈 ss:[0019FD54]
0041196E 25 01000080 and eax,0x80000001 ; eax与1 ;改变eax的值,偶数与1后eax为0,奇数与1后eax为1
00411973 79 05 jns short ConsoleA.0041197A ; 如果符号位S为0,就跳转到41197A ;SF用来反映运算结果的符号位,它与运算结果的最高位相同
00411975 48 dec eax ; ;上面eax与1的结果是0或者1,最高位始终为0,所以这个跳转一直成立
00411976 83C8 FE or eax,-0x2
00411979 40 inc eax
0041197A 85C0 test eax,eax ; eax为0时与的结果是0,ZF置1跳转,为其他数字与的结果都是1
0041197C 74 14 je short ConsoleA.00411992 ; ZF等于1时进行跳转411992
0041197E 8B45 08 mov eax,dword ptr ss:[ebp+0x8] ; eax赋值为堆栈 ss:[0019FDA0]=0019FE98,(字符串"RVRVRHLVRVLVLVRVLVL"首地址)
00411981 0345 BC add eax,dword ptr ss:[ebp-0x44] ; ss:[0019FD54]=00000001、……,eax+1 、…… ;eax的地址为第二个字符的地址19FE99
00411984 0FBE08 movsx ecx,byte ptr ds:[eax] ; 把eax的当前地址上的值,送给ecx ;因为上面的eax+1,所以取出来的是第二个字符'V'
00411987 83F1 04 xor ecx,0x4 ; ecx异或4 ;第二个字符'V'异或4
0041198A 8B55 08 mov edx,dword ptr ss:[ebp+0x8] ; 把字符串首地址ss:[0019FDA0]=0019FE98,赋值给edx
0041198D 0355 BC add edx,dword ptr ss:[ebp-0x44] ; edx加上00000001、…… ;获得当前被改变的那个字符的地址
00411990 880A mov byte ptr ds:[edx],cl ; d把异或后的值'R',赋值给原来字符串的对应位置
00411992 ^ EB C8 jmp short ConsoleA.0041195C ; 跳转到上面41195C继续循环
运行完第2轮循环,第二个字符‘V’被改成了‘R’:
运行完18轮循环,字符串RVRVRHLVRVLVLVRVLVL已经变成了RRRRRLLRRRLRLRRRLRL:
到这我们就明白了Str字符串被修改的具体原理了。
所以我们只要在源代码加上Str字符串被修改的代码就可以了:
for(i = 1 ; i < 20 ; i++){
///当前i是偶数位
if(i % 2 == 0){
Str[i-1] ^= 4;
}
}
10、正确完整的代码
源代码:
#include<stdio.h>
#include <stdlib.h>
int main(){
/// 随机数种子
srand(0xCu);
/// 存放随机数的数组,数组下标最大有100 * 20 + 20
int dword_41A138[2100];
int i , j;
///当前数组下标
int lab = 0;
for ( i = 1; i <= 20; ++i ){
///%2d:对长度为2以内的数字右对齐
printf("%2d ", i );
for ( j = 1; j <= i; ++j ){
lab = 100 * i + j;
dword_41A138[lab] = rand() % 100000;
///%5d:对长度为5以内的数字右对齐
printf("%5d ",dword_41A138[lab]);
}
printf("\n");
}
///分数
int dword_423D78 = 0;
///分数的初值默认为第一行的第一个数
dword_423D78 += dword_41A138[101];
///输入的字符串
char Str[19];
i = 1 ;
j = 1 ;
while(i < 20){
printf("%d:", i);
///右下角的值大于下面的值
if(dword_41A138[100 * (i + 1) + (j + 1)] > dword_41A138[100 * (i + 1) + j]){
Str[i-1] = 'R';
printf("%c ",Str[i-1]);
dword_423D78 += dword_41A138[100 * (++i) + (++j)];
}else{
Str[i-1] = 'L';
printf("%c ",Str[i-1]);
dword_423D78 += dword_41A138[100 * (++i) + j];
}
}
Str[19] = '\0';
///444740
printf("\n最终的分数是:%d\n", dword_423D78);
///RRRRRLLRRRLRLRRRLRL
for(i = 1 ; i < 20 ; i++){
///当前i是偶数位
if(i % 2 == 0){
Str[i-1] ^= 4;
}
}
///RVRVRHLVRVLVLVRVLVL
printf("输入的字符串为:%s\n", Str);
system("pause");
return 0;
}
运行结果:
得到正确的flag:RVRVRHLVRVLVLVRVLVL。