一道奇奇怪怪的题,直到最后也理解不了考察点是什么,希望出题人可以开源出来看看。
(自己从周五晚上开始认真看题,爆肝一整天直到周六晚上才找到答案。10个小时出一血是真的快,应该是完全没有被外围逻辑分散精力)
主要逻辑都在 sub_140007660 里。策略还是先标记出函数,然后动态调试验证。
重要函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | sub_140006DE0:scanf_s
sub_1400007F0:printf
sub140006FD0:str_xor,主要是异或解密一些常量
sub_140007180:hexdecode
sub_1400072B0:check_if_is_digit,检查一个std::string的字符是否全部是数字
sub_1400093C0:aes_cbc_decrypt,AES256 CBC 模式解密
std::string相关:
sub_140006060:stdstring_substr,取子串
sub_1400015F0:stdstring_init_from_cstr,构造函数(从char * 构造)
sub_140006230,stdstring_copy,拷贝构造函数
sub_1400015C0:str_destructor,析构函数
sub_140006130:stdstring_cstr,返回内部缓冲区指针
sub_140006660:stdstring_concat,连接两个字符串
sub_140006140:stdstring_get,取索引位置的字符
大数运算:
sub_140001EE0:bn_init_from_str,从 16 进制大端序列的std::string初始化(bn内部是小端序)
sub_140004D70:bn_to_str,转换为 16 进制大端序的std::string
sub_1400042D0:bn_powmod,模幂运算
|
关键逻辑:
0x1400080DA:
1 | scanf_s( "%s" , v32, a1.s.size); / / sub_140006DE0
|
这里限制了输入的长度上限是205
0x1400081E8:
1 2 3 4 5 6 7 8 9 10 11 | if ( inputlen < 0x59 ) / / 0x59 , 89
{
v46 = (char * )&v179.s;
if ( v179.s.capacity > = 0x10ui64 )
v46 = (char * )v179.s.s;
str_xor(v46, v179.s.size, "Y?j0?" );
v47 = (const char * )&v179.s;
if ( v179.s.capacity > = 0x10ui64 )
v47 = (const char * )v179.s.s;
printf(v47); / / "no!\n" 0x140008238
...
|
检查了输入长度最小为89。
特别的,由于这个printf输出的是no,所以这个if块后面的部分完全不需要看(从0x14000824B开始到0x140008C91,在F5伪代码中占了足足一半)(最开始没有先注意到这个printf,浪费了一些时间。这些代码似乎都是内联进来的析构函数)
0x140008C91:
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 | stdstring_substr(&a1, &input_first_85_bytes, 0i64 , 85ui64 );
stdstring_init_from_cstr(
&v208,
"B20446102D1C343D0575674CA28EBC0419BCFE4D75682C2AC81C9502454650BDDAEF6968AF269B54C182" );
stdstring_init_from_cstr(
&v189,
"4F62187B5F6590C6CFF0FBDBBEBDAF60AA861BD2F66F8F7FFD57A66AE50DB7D2FFFFFFFFFFFFFFFFFFFFF" );
bn_init_from_str(&v207, &v189);
str_destructor(&v189);
stdstring_init_from_cstr(&v189, "11" );
bn_init_from_str(&v211, &v189);
str_destructor(&v189);
v95 = stdstring_cstr(&input_first_85_bytes);
stdstring_init_from_cstr(&v210, v95);
bn_init_from_str(&v206, &v210);
str_destructor(&v210);
bn_init_from_str(&v205, &v208);
bn_powmod(&v205, &v204, &v206, &v207);
bn_to_str(&v204, &v195);
v96 = stdstring_cstr(&v195);
hexdecode(v96, v97);
v98 = stdstring_cstr(&v195);
v100 = str_xor(v98, v99, "Y?j0?" );
stdstring_init_from_cstr(&come_from_input_first_85_bytes, v100);
origin_size_of_come_from_input_first_85_bytes = come_from_input_first_85_bytes.s.size;
stdstring_init_from_cstr(&v197, byte_1400710A0);
stdstring_substr(&a1, &from_input_4_bytes_at_85, 85ui64 , 4ui64 );
sub_140009ED0(&v180, from_input_4_bytes_at_85.s.size);
* (_QWORD * )v214 = &v189;
v215 = &v210;
v101 = sub_14000A140(&from_input_4_bytes_at_85, &v189);
v102 = sub_14000A1E0(&from_input_4_bytes_at_85, &v210);
sub_14000A270(&v180, v102, v101);
v103 = stdstring_cstr(&v197);
str_xor(v103, v197.s.size, "Y?j0?" ); / / "ABCDEF0123456789"
v187 = (unsigned __int64)&v189;
v104 = stdstring_copy(&v189, &from_input_4_bytes_at_85);
if ( !check_if_is_digit(v104) || from_input_4_bytes_at_85.s.size ! = 4 )
goto LABEL_275;
ii = 0i64 ;
start = v180.v.start;
...
|
截取输入的前85个字节,按照十六进制解析初始化为一个大数,作为bn_powmod的指数参与运算(底数和模是上面的两个常量),计算结果再转回十六进制、hexdecode,暂时存入come_from_input_first_85_bytes变量中。
这里同时初始化了v197,后面会用到。
截取输入索引85开始的4个字节。由于LABEL_275输出no,所以不能跳转过去,这4个字节要求是数字。
后面是一个看起来很复杂的三重循环(其实到最后也没理清楚),但是值得关注的只有中间一部分:
0x140009178:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | if ( v112 ! = kk && v112 ! = jj && v113 ! = ii )
{
v114 = (char)v215 + SLOBYTE(v214[ 0 ]);
v115 = start[v113];
if ( v114 = = v115 + v111 && v114 = = (char)v215 + v111 && v114 = = v115 + SLOBYTE(v214[ 0 ]) )
break ;
}
+ + v112;
+ + v113;
if ( v112 > = 4 )
goto LABEL_201;
}
kk = 5 ;
ii_i = 5i64 ;
v110 = 5i64 ;
jj = 5 ;
v187 = 5i64 ;
ii = 5i64 ;
v116 = stdstring_concat(&v189, &from_input_4_bytes_at_85, &come_from_input_first_85_bytes);
strnode_assign(&come_from_input_first_85_bytes, v116);
str_destructor(&v189);
start = v180.v.start;
|
0x140009288
1 2 | if ( come_from_input_first_85_bytes.s.size > (unsigned __int64)origin_size_of_come_from_input_first_85_bytes )
...
|
由于 0x140009288 的判断存在,所以 stdstring_concat 必须要被调用到,所以要从上面的break跳出来,而不能goto LABEL_201。
从 v114 == v115 + v111 && v114 == (char)v215 + v111 && v114 == v115 + SLOBYTE(v214[0])
推出 v115 == v215
且 v111 == SLOBYTE(v214[0])
;同时反向可验证只要满足这两个等式,if的条件一定会通过。
动态调试发现这4个数分别就是输入索引85开始的4个字节,约束是第1等于第3,第2等于第4。随手试,"0000" 不行,但 "0101" 可以(不知道具体原因)。
0x1400092D1:
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 | v214[ 0 ] = 8 ;
v214[ 1 ] = 14 ;
stdstring_resize(&stru_140079DB0, 0xDC0ui64 );
v118 = stdstring_cstr(&stru_140079DB0);
memcpy(v118, Src, 0xDC0ui64 );
v119 = stdstring_cstr(&stru_140079DB0);
str_xor(v119, stru_140079DB0.s.size, "?x}da" );
src_size = (unsigned __int64)stru_140079DB0.s.size >> 1 ;
v121 = stdstring_cstr(&stru_140079DB0);
hexdecode(v121, src_size);
v122 = stdstring_cstr(&come_from_input_first_85_bytes);
str_xor(v122 + 4 , v123, "Y?j0?" );
stdstring_cstr(&v197); / / iv
stdstring_cstr(&come_from_input_first_85_bytes); / / key
from_src = stdstring_cstr(&stru_140079DB0);
v127 = aes_cbc_decrypt(v214, from_src, src_size, v126, v125);
stdstring_init_from_cstr(&v196, v127);
v128 = stdstring_cstr(&v185);
str_xor(v128, v185.s.size, "!?>d*" );
v129 = stdstring_cstr(&v181);
str_xor(v129, v181.s.size, "?x)da" );
v130 = stdstring_cstr(&v181);
v131 = stdstring_cstr(&v185);
ModuleHandleA = GetModuleHandleA(v131); / / kernel32.dll
RtlFillMemory = (__int64)GetProcAddress(ModuleHandleA, v130); / / RtlFillMemory
if ( !RtlFillMemory )
goto LABEL_274;
virtualalloc_region_1 = (char * )VirtualAllocEx(hProcess, 0i64 , v196.s.size, 0x3000u , 0x40u );
v134 = (char * )malloc2(a1.s.size);
v135 = stdstring_cstr(&v186);
str_xor(v135, v186.s.size, "*s>0?" );
v136 = stdstring_cstr(&v186); / / "kctf"
v137 = v134 - v136;
|
这段主要是 aes_cbc_decrypt 解密一段固定常量。算法是 AES 256,密钥 key 是 come_from_input_first_85_bytes(v126),iv 是 v197(v125)。
(v125和v126在IDA中被标记为VALUE MAY BE UNDEFINED,需要看汇编+动态调试确认)
iv: v197在很早以前就计算出来了,值是固定的"ABCDEF0123456789"。
key:come_from_input_first_85_bytes 原本是模幂的计算结果的hexdecode,但是经过上面的三重循环后,其前面被附加了输入索引85开始的4个字节。无论如何,key是由输入的前89个字节完全确定的。跟进aes内部的key expansion,由 0x140001167 处的循环可以判断出 key 的长度为 32,即 AES256。
0x1400094F6:
1 2 3 4 5 6 7 | v139 = stdstring_substr(&a1, &v189, 89ui64 , a1.s.size);
v140 = stdstring_cstr(v139);
v141 = v134 - 1 ;
do
+ + v141;
while ( * v141 );
strcpy(v141, v140);
|
这里取了输入索引89以后的所有字节
0x140009545:
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 | if ( a1.s.size )
{
...
if ( v145[i_2] > = 'a' )
{
if ( * stdstring_get(&a1, j_2) < = 'z' )
{
v146 = antidebug;
* (_BYTE * )antidebug = 1 ;
goto LABEL_237;
}
size_2 = a1.s.size;
...
if ( ( * (char * )(i_2 + v147) < '0' || * stdstring_get(&a1, j_2) > '9' )
&& ( * stdstring_get(&a1, j_2) < 'A' || * stdstring_get(&a1, j_2) > 'Z' ) )
{
v146 = antidebug;
* (_BYTE * )antidebug = 2 ;
}
else
{
v146 = antidebug;
* (_BYTE * )antidebug = 0 ; / / !
}
LABEL_237:
antidebug = v146 + 1 ;
if ( j_2 > 89 && * stdstring_get(&a1, j_2) = = '0' && * stdstring_get(&a1, j_2 - 1i64 ) = = '0' )
{
v148 = stdstring_cstr(&v179);
str_xor(v148, v179.s.size, "Y?j0?" );
v149 = stdstring_cstr(&v179);
printf(v149); / / "no!\n"
str_destructor(&v196);
goto LABEL_276;
|
如果索引89以后的的字节不为空(即输入长度大于89),则会进入这个if判断,核心部分是检查输入的全部字节为[0-9A-Z],同时在索引89之后不能有两个连续的'0'。
0x14000979E:
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 | TickCount64 = GetTickCount64();
v153 = ( int )v150 + 5 ;
v154 = (char * )lpBaseAddress;
v155 = ( int )hProcess;
WriteProcessMemory(hProcess, lpBaseAddress, v134, v153, (SIZE_T * )NumberOfBytesWritten);
v156 = (char * )&try_again.s;
if ( try_again.s.capacity > = 0x10ui64 )
v156 = (char * )try_again.s.s;
str_xor(v156, try_again.s.size, "Y?j0?" );
v157 = &try_again.s;
if ( try_again.s.capacity > = 0x10ui64 )
v157 = (const void * )try_again.s.s;
* (_QWORD * )v214 = v154 + 220 ;
memcpy(v154 + 220 , v157, try_again.s.size); / / "Try again!\n"
v158 = (char * )&good.s;
if ( good.s.capacity > = 0x10ui64 )
v158 = (char * )good.s.s;
str_xor(v158, good.s.size, "Y?j0?" );
v159 = &good.s;
if ( good.s.capacity > = 0x10ui64 )
v159 = (const void * )good.s.s;
v215 = (struct strnode * )(v154 + 240 );
memcpy(v154 + 240 , v159, good.s.size); / / "good!\n"
shellcode_1 = (unsigned __int8 * )&v196.s;
if ( v196.s.capacity > = 0x10ui64 )
shellcode_1 = (unsigned __int8 * )v196.s.s;
|
这里是把v134写入最开始分配出来的内存中。动态调试可以发现开头4个字符固定是"kctf",后面输入索引89以后的字节的hexdecode。同时也向偏移220和240的两个地方写入了 "Try again!\n" 和 "good!\n" 两个字符串。
0x1400098D0:
1 2 3 4 5 6 7 | if ( (unsigned int )sub_140007410(v155, virtualalloc_region_1, shellcode_1, LODWORD(v196.s.size) + 1 )
|| (v176 = 0i64 ,
v175 = 0i64 ,
v174 = 0i64 ,
v173 = 0i64 ,
v172 = 1 ,
(unsigned int )sub_14000B629(( int )&hThread, 0x1FFFFF , 0 , v155, ( int )ExitThread, 0 )) )
|
0x14000758C:(在 sub_140007410 内部)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | if ( !(unsigned int )sub_14000B629(( int )&hObject, 0x1FFFFF , 0 , a4, ( int )ExitThread, 0 ) )
{
if ( !r9d0 )
{
LABEL_15:
ResumeThread(hObject);
WaitForSingleObject(hObject, 0xFFFFFFFF );
CloseHandle(hObject);
return 0i64 ;
}
while ( !(unsigned int )sub_14000AF4E((__int64)hObject, RtlFillMemory, (__int64)&a2[v12], 1i64 , * a3, v19) )
{
+ + v12;
+ + a3;
if ( v12 > = r9d0 )
goto LABEL_15;
|
sub_14000AF4E里面是很多syscall,没看懂是什么原理,但是从对它的调用 sub_14000AF4E((__int64)hObject, RtlFillMemory, (__int64)&a2[v12], 1i64, *a3, v19)
来看,应该等价于调用第二个参数指示的函数。
根据上下文,这两块逻辑应该是创建一个线程,动态调试发现前面aes解密出来的shellcode被写入一块内存中。
0x14000992C:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | sub_14000AF4E((__int64)hThread, (__int64)virtualalloc_region_1, 0i64 , 0i64 , 0 , v171[ 0 ]);
v161 = ( int )(GetTickCount64() - TickCount64) < 6000 ;
v162 = (_BYTE * )antidebug;
if ( !v161 )
{
* (_BYTE * )antidebug = 9 ;
antidebug = (__int64) + + v162;
}
if ( debugger_present )
{
* v162 = 6 ;
antidebug = (__int64)(v162 + 1 );
}
ResumeThread(hThread);
WaitForSingleObject(hThread, 0xFFFFFFFF );
CloseHandle(hThread);
if ( (unsigned int )sub_140006E40() ! = 0x478E )
|
sub_14000AF4E再次出现了,所以之前解密出的shellcode会被当做函数(注意,函数是需要ret的,后面会利用这一点),在ResumeThread时执行。
(由于shellcode是根据输入的前89个字节解密的,所以如果随便输入,动态调试到这里时程序会因为非法指令而异常退出。)
暂时修改rip跳过这里,先调试下一段代码。
0x1400099CF:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | v164 = (UUID * )VirtualAlloc( 0i64 , 0x100000ui64 , 0x3000u , 0x40u );
v165 = v164;
v166 = (RPC_CSTR * )off_140076EB0;
do
{
if ( UuidFromStringA( * v166, v165) )
{
...
while ( (__int64)v166 < (__int64)&unk_140077218 );
if ( dword_140079CE4 )
{
v167 = antidebug;
* (_BYTE * )antidebug = 7 ;
antidebug = v167 + 1 ;
}
EnumSystemLocalesA((LOCALE_ENUMPROCA)v164, 0 );
if ( v154[ 4 ] * v154[ 4 ] + 5 * v154[ 8 ] * v154[ 8 ] * v154[ 8 ] - 2 * v154[ 4 ] * v154[ 6 ] * v154[ 6 ] + 3 )
printf(v154 + 220 ); / / "Try again!\n" really ?
else
printf(v154 + 240 ); / / "good!\n" really ?
str_destructor(&v196);
|
最后一块逻辑是经典的利用uuid隐藏shellcode:再次分配了一段内存,然后UuidFromStringA从很多uuid常量中解出shellcode,作为第一个参数调用EnumSystemLocalesA(第一个参数会被当做函数在EnumSystemLocalesA中回调)
回顾前面对偏移220和240位置的写入,似乎只要 v154[4] * v154[4] + 5 * v154[8] * v154[8] * v154[8] - 2 * v154[4] * v154[6] * v154[6] + 3 == 0
就会成功,但是,稍微爆破一下 v154[4], v154[6], v154[8]
这三个字节,就会发现是无解的。
所以,EnumSystemLocalesA调用的shellcode内部肯定做了一些事情。
开头是自修改,运行完自修改(到偏移0x23的位置)再dump。
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 | __int64 sub_23()
{
/ / [COLLAPSED LOCAL DECLARATIONS. PRESS KEYPAD CTRL - "+" TO EXPAND]
CreateToolhelo32Snapshot = (__int64 (__fastcall * )(__int64))sub_5F7( 4170047302 ); / / CreateToolhelo32Snapshot
OpenProcess = (__int64 (__fastcall * )(__int64, _QWORD))sub_5F7( - 49588825 ); / / OpenProcess
VirtualQueryEx = (SIZE_T (__fastcall * )(HANDLE, LPCVOID, PMEMORY_BASIC_INFORMATION, SIZE_T))sub_5F7( 37938943 ); / / VirtualQueryEx
Process32First = (__int64 (__fastcall * )(__int64, int * ))sub_5F7( 1060402837 ); / / Process32First
Process32Next = (__int64 (__fastcall * )(__int64, int * ))sub_5F7( - 1813961927 ); / / Process32Next
CloseHandle = (void (__fastcall * )(__int64))sub_5F7( 480663025 ); / / CloseHandle
GetCurrentProcessId = (__int64 ( * )(void))sub_5F7( 55981281 ); / / GetCurrentProcessId
v51 = 0i64 ;
v46 = 568 ;
v5 = 0 ;
v6 = CreateToolhelo32Snapshot( 2i64 );
v7 = v6;
v43 = v6;
if ( v6 = = - 1 )
return 0xFFFFFFFFi64 ;
for ( i = Process32First(v6, &v46); i; i = Process32Next(v7, &v46) )
{
if ( v47 = = (unsigned int )GetCurrentProcessId() )
{
v10 = (void * )OpenProcess( 0x2000000i64 , 0i64 );
v51 = v10;
if ( v10 )
{
v11 = 0i64 ;
while ( 1 )
{
do
{
if ( !(__int64)VirtualQueryEx(v10, v11, &v45, 48i64 ) )
{
v7 = v43;
goto LABEL_79;
}
v11 = (char * )v45.BaseAddress + v45.RegionSize;
v10 = v51;
}
while ( v45.State ! = 4096 || v45.AllocationProtect ! = 64 );
v12 = GetCurrentProcessId();
BaseAddress = (unsigned __int8 * )v45.BaseAddress;
if ( v47 = = v12 )
v5 = sub_6C3( * (_DWORD * )v45.BaseAddress); / / check 'ftck'
if ( v5 )
break ;
v10 = v51;
}
length = BaseAddress[ 4 ];
length_ = length;
s = (char * )(BaseAddress + 5 );
for ( j = 0 ; j < length; + + j )
{
v17 = * s;
t[ 2 * j] = (unsigned __int8) * s >> 4 ;
t[ 2 * j + 1 ] = v17 & 0xF ;
+ + s;
}
v50 = * s ! = 0 ;
v18 = 0 ;
v19 = 0 ;
for ( k = 0 ; k < length; + + k )
{
v21 = t[ 2 * k];
v22 = t[ 2 * k + 1 ];
if ( s[ 10 * v21 + v22] = = 1 )
+ + v18;
if ( k > 0 )
{
v23 = v21 - t[ 2 * k - 2 ];
if ( v23 * v23 ! = 1 && (v22 - t[ 2 * k - 1 ]) * (v22 - t[ 2 * k - 1 ]) ! = 1 )
+ + v19;
}
length = length_;
}
v24 = k - 1 ;
v25 = t[ 2 * v24 + 1 ];
if ( v25 && v25 ! = 9 && t[ 2 * v24] && t[ 2 * v24] ! = 9 )
+ + v19;
if ( t[ 2 * v24] = = t[ 0 ] && v25 = = t[ 1 ] )
+ + v19;
s_100 = s + 100 ;
v27 = 0 ;
v28 = 0 ;
for ( m = 0 ; m < length; + + m )
{
if ( t[ 1 ] )
+ + v27;
v30 = t[ 2 * m];
v31 = t[ 2 * m + 1 ];
if ( s_100[ 10 * v30 + v31] = = 1 )
+ + v27;
if ( m > 0 )
{
v32 = v30 - t[ 2 * m - 2 ];
if ( v32 * v32 ! = 1 && (v31 - t[ 2 * m - 1 ]) * (v31 - t[ 2 * m - 1 ]) ! = 1 )
+ + v28;
}
length = length_;
}
v33 = m - 1 ;
v34 = t[ 2 * v33 + 1 ];
if ( v34 && v34 ! = 9 && t[ 2 * v33] && t[ 2 * v33] ! = 9 )
+ + v28;
if ( t[ 2 * v33] = = t[ 0 ] && v34 = = t[ 1 ] )
+ + v28;
if ( v34 > 9 )
+ + v28;
s_ = s_100 - 100 ;
v36 = 0 ;
for ( n = 0 ; n < 10 ; + + n )
{
for ( ii = 0 ; ii < 10 ; + + ii )
v36 + = (unsigned __int8)s_[ 10 * n + ii];
}
v39 = s_ + 200 ;
v40 = 15 - length_;
while ( 1 )
{
v41 = v40 < 0 ;
if ( !v40 )
break ;
if ( v40 > 0 )
{
- - v40;
+ + v39;
v41 = v40 < 0 ;
}
if ( v41 )
{
+ + v40;
- - v39;
}
}
if ( v27 > 0 || v28 > 0 || v18 > 0 || v19 > 0 || v36 || v50 > 0 )
strcpy(v39, "no!" ); / / 0xdc , 220
else
strcpy(v39, "good!" );
strcpy(v39 + 20 , "no!" );
break ;
}
}
LABEL_79:
;
}
CloseHandle(v43);
return ((__int64 (__fastcall * )(void * ))CloseHandle)(v51);
}
|
结合动态调试,整体逻辑还是相对清晰的。查找"kctf"找内存段,BaseAddress即为主要函数sub_140007660开始分配的内存区域,前4个字节是"kctf",后面跟着的是输入索引89以后的字节hexdecode的内容,然后是一块空白区域、一串01,以及220偏移处的"Try again!\n"和240偏移处的"good!\n"。
如果通过了shellcode末尾的检查,则会把220偏移处的"Try again!\n"修改为"good!\n",所以真正的检查逻辑就是这段shellcode。
这段shellcode整体逻辑如下(不太好描述,直接看代码吧。不过上面ida输出的伪代码可以复制出来编译,也能本地测试):
整块内存前4个字节是"kctf",然后是输入索引89以后的字节hexdecode的内容(这块内容的第一个字节是长度;后面的字节会hexencode,奇偶相间分别构成两个序列,每个序列的相邻两个值相差要求为1,且第二个序列要以0开头,同时两个序列至少有一个是以0或9结尾)。后面是两个10*10的区域,对每个区域,前面的两个序列分别是横纵坐标,检查要求所有坐标的位置都是0;以及,第一个区域的全部100个字节都要是0(第二个区域无此要求)。
反调试:
代码中有大量反调试,并且会修改 antidebug(0x140079CD0)这个全局变量指向的内存,而且位置会向后递增。
0x1400076C2
1 2 3 4 5 6 7 8 9 10 11 12 | hProcess = GetCurrentProcess();
lpBaseAddress_ = (char * )VirtualAllocEx(hProcess, 0i64 , 0x1F4ui64 , 0x3000u , 0x40u );
lpBaseAddress = lpBaseAddress_;
si128 = _mm_load_si128((const __m128i * )&xmmword_140071FE0);
* (__m128i * )(lpBaseAddress_ + 120 ) = si128;
* (__m128i * )(lpBaseAddress_ + 136 ) = si128;
* (__m128i * )(lpBaseAddress_ + 152 ) = si128;
* (__m128i * )(lpBaseAddress_ + 168 ) = si128;
* (__m128i * )(lpBaseAddress_ + 184 ) = si128;
* (__m128i * )(lpBaseAddress_ + 200 ) = si128;
* ((_DWORD * )lpBaseAddress_ + 54 ) = _mm_cvtsi128_si32(si128);
antidebug = (__int64)(lpBaseAddress_ + 21 );
|
这是 sub_140007660 开头的一段代码,lpBaseAddress_ 是最后shellcode检查的内存段,动态调试看到的01序列在这里初始化;另外,antidebug也落在了两个10*10的区域的范围内。
反调试很多,大部分都能在调试器中忽略并继续,但这一处有些特殊:
0x140007375 (在 check_if_is_digit 中)
1 2 3 | v6 = SetUnhandledExceptionFilter((LPTOP_LEVEL_EXCEPTION_FILTER)TopLevelExceptionFilter);
RaiseException( 0xC000008E , 0 , 0 , 0i64 );
SetUnhandledExceptionFilter(v6);
|
1 2 3 4 5 | __int64 __fastcall TopLevelExceptionFilter(struct _EXCEPTION_POINTERS * ExceptionInfo)
{
dword_140076EA0 = 0 ;
return 0xFFFFFFFFi64 ;
}
|
在x64dbg调试时如果忽略这里的异常,程序并不会调用TopLevelExceptionFilter,而是两次异常后直接退出,但是自己写代码测试发现如果不附加调试器就完全正常。
猜测原因是这里的异常由RaiseException api主动引发,异常的派生可能是内核处理?而UnhandledException在有调试器附加的时候会被调试器接收到,即使调试器选择忽略,但已经影响了系统的处理逻辑?(不太确定,熟悉Windows的朋友可以留言解释一下)
大部分反调试如果触发,会在 antidebug++ 相应的位置赋值为非0值,这会直接影响最后shellcode的检查。
但是有一处例外,在前面检查输入字符是否为[0-9A-Z]的位置(0x140009545的if块内部的0x1400096B3位置),对于每个符合要求的字符都会在 antidebug++ 指向的位置多赋值一个 0,这意味着如果输入较长,会被赋值更多的0,有利于最后的shellcode检查。
分析的差不多了,现在是时候寻找程序的正确输入了
首先是最后shellcode检查,受限于输入最长不能超过205个字符,所以 0x1400096B3 antidebug++ 赋值0的位置不会超过 0xDC (220) 的位置,而这里的位置保存在 "Try again!\n" 非 0 字符,也即中间的空白区域小于两个 10*10 区域(大概只有一百几十个字节),所以需要让索引尽可能小。
两个10*10 区域共用一套索引,第一个区域100个字节都要是0,则第二个区域只有开头的几十个字节才能是0。区域索引是二维坐标,所以横坐标要尽可能小。
横纵坐标各自的序列相邻位置差值必须是1,且第一个纵坐标必须是0,最后一个横坐标和纵坐标至少有1个是0或9;另外由于坐标值直接来源于原始输入,也受到原始输入不能存在相邻的0的限制。
基于以上限制,考虑先把最后一个纵坐标限定为9,那么一个最短的可行坐标序列如下:
1 2 3 4 5 6 7 8 9 10 | ( 1 , 0 )
( 0 , 1 )
( 1 , 2 )
( 0 , 3 )
( 1 , 4 )
( 0 , 5 )
( 1 , 6 )
( 0 , 7 )
( 1 , 8 )
( 0 , 9 )
|
对应到原始输入索引89之后的字节即为 "10011203140516071809"
实测发现,空白区域还是少了几个字节。根据之前的分析,增大2字节输入长度会使得 antidebug++ 赋值2个0,但是在此处只增加1个字节,合计增加了1个0的空白区域,所以适当增加长度,例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | ( 1 , 0 )
( 0 , 1 )
( 0 , 3 )
( 1 , 2 )
( 0 , 3 )
( 1 , 2 )
( 1 , 2 )
( 0 , 3 )
( 1 , 4 )
( 0 , 5 )
( 1 , 6 )
( 0 , 7 )
( 1 , 8 )
( 0 , 9 )
|
对应到原始输入索引89之后的字节即为 "1021120312031203140516071809",可以通过shellcode的检查。显然这里的构造方法不唯一。
还剩下最后一个问题没有解决:前面0x14000992C代码块会在ResumeThread(hThread)
时异常退出。这里实际上是由于根据输入前89字节解密出的shellcode函数存在非法指令,所以需要控制解密出来的字节。
既然shellcode是需要ret的函数,最简单的方式是控制ret指令(C3)在第一个字节,就可以不触发异常的通过这里。
无法反推到原始输入(因为要逆推aes key,再解离散对数,都是不可能的任务),但是由于只需要控制解密后的第一个字节,所以可以直接爆破
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 | from Crypto.Cipher import AES
x = 0xB20446102D1C343D0575674CA28EBC0419BCFE4D75682C2AC81C9502454650BDDAEF6968AF269B54C182
n = 0x4F62187B5F6590C6CFF0FBDBBEBDAF60AA861BD2F66F8F7FFD57A66AE50DB7D2FFFFFFFFFFFFFFFFFFFFF
y0 = 0x0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01234
def strxor(s, k):
return bytes(a^b for a,b in zip (s, k * 50 ))
def test1(y0):
tmp1 = pow (x, y0, n)
buf1 = hex (tmp1).replace( "0x" ,"")
if len (buf1) & 1 :
buf1 + = "0"
buf2 = bytes.fromhex(buf1)
key = (b "0101" + buf2)[: 32 ]
iv = b "ABCDEF0123456789"
cipher_raw = bytes.fromhex( "06 4C 1E 53 00 06 48 48 07 56 5B 1B 4A 50 03 06 4A 45 5D 04 0A 40 44 07 58 07 1C 1C 57 00 5D 1C " )
cipher = bytes.fromhex(strxor(cipher_raw, b "?x}da" ).decode())
cipher = cipher[: 16 ]
ctx = AES.new(key, AES.MODE_CBC, iv)
plain = ctx.decrypt(cipher)
print (plain. hex ())
return plain
y = y0
while True :
r = test1(y)
if r[ 0 ] = = 0xc3 :
print ( hex (y).upper())
break
y + = 1
|
得到一个输入 "123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01243"
把三部分拼起来
1 2 3 | 123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF01243
0101
1021120312031203140516071809
|
得到一个可行的答案:
1 | 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0124301010E1021120312031203140516071809
|
通过验证:
1 2 3 4 | C:\>NoLimit.exe
Please enter your key: 0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0124301010E1021120312031203140516071809
Start cheking your key ...
good!
|
(按照上面的分析,显然题目存在无数的解,所以非常好奇题目的源码逻辑是怎样的)
看雪2022 KCTF 秋季赛 防守篇规则,征题截止日期11月12日!(iPhone 14等你拿!)
最后于 8小时前
被mb_mgodlfyn编辑
,原因: