-
-
[原创]看雪 2023 KCTF 年度赛 第六题 至暗时刻 解题过程(数独)
-
2023-9-14 23:06 3007
-
一、反调处理
直接扔调试器运行,运行后黑框框没有内容。换终端里运行,提示请输入。
意味着有反调试。
于是打开反反调插件,SyllaHide默认配置随便选第1个、第2个或者SharpOD全部打勾。
这时在调试器运行,能执行到提示请输入。
二、初步分析
静态分析加载完,进去main就能看见入口sub被创建线程。
跟进去,四处查看,初步情况如下:
字符串异或加密,解密密钥带在解密函数(0x1400013B0)入参。
有一些重要函数是放了一些4字节常量然后走到syscall,且无法输出伪代码。
1. 字符串处理
考虑到字符串不多,先对解密函数返回的ret指令(0x14000144E)下日志断点,输出明文字符串查看。
1 | dec_str {utf8@cax} |
测试运行一次,字符串并不多:
1 2 3 4 | dec_str Please enter your key: dec_str kctf dec_str kernel32.dll dec_str RtlFillMemory |
2. syscall处理
将那个syscall指令的函数设置成__fastcall 4个参数,这时基本都正常输出伪代码。如果其他函数出现参数特别多的情况,也先给他降到4个。
进一步分析syscall涉及的函数:
1 2 3 | 第 1 层: 0x140002A10 :call_syscall,通过syscall指令发起系统调用。 第 2 层: 0x1400029C4 :get_syscall_index 该函数入参为先前赋值的 4 字节常量,用来获取对应的系统调用号。 第 3 层: 0x140002818 :populate_syscall_index 动态从ntdll.dll建立好系统调用索引查询表。 |
考虑到涉及函数也不是很多,还是采用日志断点输出的方法。
在0x140002818:populate_syscall_index函数中,
下2个日志断点:
- 0x1400028F6:开始计算函数hash的地方,用于输出函数名字符串。
1 | fname = {utf8@r9} |
- 0x140002911:保存函数名hash的地方,输出hash。
1 | hash = 0x {x:ebx} |
重来运行一次,日志中就得到了全部函数名与hash的对应。
复制日志到文本备用。
根据对应关系,重命名部分函数:
ZwAllocateVirtualMemory
ZwWriteVirtualMemory
ZwCreateThreadEx
ZwQueueApcThread_140002E4E
同时调整这些函数的参数数量,重点是ZwQueueApcThread,它有5个参数。
3. 初步流程
把syscall函数都重命名之后,流程变得清晰。
- 分配了一块内存BYTE* pMem。
- 复制了1个字符串进去,字符串里面包含了输入串。
- 通过ZwQueueApcThread RtlFillMemory1个字节1个字节地把一块shellcode复制到pMem+500位置。
- 再通过ZwQueueApcThread pMem+500执行shellcode。
- 执行完之后,根据pMem + 67处的字符串作出ok与no判断。
三、Dump Shellcode
在ZwQueueApcThread pMem+500 call处(0x140001D9C)下断点,断下后对第2个参数即RDX寄存器跳转到反汇编窗口,对要执行的shellcode入口下断点,运行即断在shellcode入口。
移除入口处断点,单步步进一次,那个call很神奇,call到的自己的最后一个机器码。
把下一条指令地址压栈备用的同时,使得一般的反汇编器不能从call那个地方反汇编下去。
因而需要从call指令的最后一个字节开始反汇编。
此时用savedata保存shellcode单独分析,从pMem处开始,长度500+2347 = 0xb1f。
比如:
1 | savedata c:\temp\sc. bin , 0x0000023676EB0000 , 0xb1f |
然后把sc.bin按dump时的pMem地址作为基址加载到分析工具。
后来发现在此处dump还不合适,加载之后发现call目标不再范围内。
单步研究后发现,紧接着代码还有一处循环自修改。
改为在循环之后dump,得到的shellcode即可全部正常加载出代码。
四、Shellcode分析
1. 动态定位API
shellcode加载之后,发现有动态定位API的call。
shellcode需要动态定位API才能位置无关。
无需要跟进分析,步过call,根据返回值显示的函数名称信息,重命名局部函数指针即可。
感兴趣的可以参考很久以前写过的动态定位API文章:
https://bbs.kanxue.com/thread-203319.htm 。
2. 主要流程
重命名局部函数指针之后,逻辑就很清晰了。
- 通过进程快照遍历进程,找到自己进程,然后找到pMem
- 将pMem+4传给校验函数,姑且叫checkall
- 根据checkall返回值,赋值主程序用于判断ok或no的字符串。
3. 验证算法
checkall里面依次调用check1,check2,check3,且3个函数都调用同一个公共check函数。
分析后check原型大致如下:
1 | __int64 __fastcall check(char * Sz, int _shiwei, int __gewei) |
后两个入参为十位、个位,返回百位为正确分支。
看了check1 2 3的检查逻辑之后,发现加起来刚好就是数独的规则。
代码还原如下:
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 | char __fastcall checkall(char * Sz) { unsigned int i; int j; int k; i = 0 ; while ( check1(Sz, i) && check2(Sz, i) ) { if ( ( int ) + + i > = 9 ) { j = 0 ; label_cont: k = 0 ; while ( check3(Sz, (unsigned int )j, (unsigned int )k) ) { k + = 3 ; if ( k > = 9 ) { j + = 3 ; if ( j < 9 ) goto label_cont; return 1 ; } } return 0 ; } } return 0 ; } char __fastcall check1(char * Input , __int64 i) { int j; int i_; unsigned int oi; unsigned int dws[ 9 ]; memset(dws, 0 , sizeof(dws)); j = 0 ; i_ = i; while ( 1 ) { oi = check( Input , i_, j) - 1 ; if ( oi > 8 || dws[oi] ) break ; + + j; dws[oi] = 1 ; if ( j > = 9 ) return 1 ; } return 0 ; } char __fastcall check2(char * Sz, __int64 i) { int j; int i_; unsigned int oi; unsigned int dws[ 9 ]; memset(dws, 0 , sizeof(dws)); j = 0 ; i_ = i; while ( 1 ) { oi = check(Sz, j, i_) - 1 ; if ( oi > 8 || dws[oi] ) break ; + + j; dws[oi] = 1 ; if ( j > = 9 ) return 1 ; } return 0 ; } char __fastcall check3(char * Input , __int64 j, __int64 k) { int addj; int k_; int j_; int addk; unsigned int oi; unsigned int dws[ 9 ]; memset(dws, 0 , sizeof(dws)); addj = 0 ; k_ = k; j_ = j; while ( 2 ) { for ( addk = 0 ; addk < 3 ; + + addk ) { oi = check( Input , addj + j_, addk + k_) - 1 ; if ( oi > 8 || dws[oi] ) return 0 ; dws[oi] = 1 ; } if ( + + addj < 3 ) continue ; break ; } return 1 ; } |
并且通过对check的分析,得知先前被写到pMem开头的字符串结构与每段的处理:
1 | kctf + 63 个字符为 21 组已知的 3 位数 + 需要输入 180 个字符( 60 组 30 位数) + 120 个字符(可得出前面 60 组数的个位与十位)。 |
现在就缺少60个百位,并且这60个百位与已知的21个百位,
按所在3位数的十位与个位作为二维索引,建立出一个数独。
求解出数独,再把求出的60个百位与各自对应的十位、个位组成3位数。即可得出flag。
如下python代码实现。
五、Python Keygen
1 | pip install sudokutools more - itertools |
安装三方库后可直接运行,输出flag。
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 | from more_itertools import chunked from sudokutools.solve import bruteforce from sudokutools.sudoku import Sudoku head = '3201382652D139C0E22132DF1BC2212EA0991650A229B36436823D0B13D51E6' tail = '677116575313142309154604431859253431473963507533496829080645035455771774602058076430276921790210013736267644383505517280' # 头部63个字符 每3个字符作为1个16进制数转到10进制 nums = [ int (''.join(nstr), 16 ) for nstr in chunked(head, 3 )] sd = [[ 0 ] * 9 for _ in range ( 9 )] # 构成数独 for n in nums: sd[n / / 10 % 10 ][n % 10 ] = n / / 100 for r in sd: print (r) sudoku = '\n' .join(''.join( map ( str , r)) for r in sd) print ( '\nsudoku:\n' , sudoku, sep = '') # 求数独解 sudoku = Sudoku.decode(sudoku) for solution in bruteforce(sudoku): print ( '\nans:\n' , solution, sep = '') ans = list (chunked(solution.encode(), 9 )) finvals = [] # 尾部120个字符 每2个按10进制转成整数后再转成9进制 最后会把9进制的位当十进制的位使用 for dec in chunked(tail, 2 ): d = int (''.join(dec)) ge = d % 9 shi = d / / 9 # 以十位、个位去取出对应的百位,组成3位数 finvals.append( int (ans[shi][ge]) * 100 + shi * 10 + ge) # 最后将60个3位数 转16进制后拼接 即得到flag print ( '\nflag =' , ' '.join(' {: 03X }'. format (f) for f in finvals)) |
输出:
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 | [ 8 , 0 , 0 , 0 , 0 , 0 , 0 , 0 , 0 ] [ 0 , 0 , 3 , 6 , 0 , 0 , 0 , 0 , 0 ] [ 0 , 7 , 0 , 0 , 9 , 0 , 2 , 0 , 0 ] [ 0 , 5 , 0 , 0 , 0 , 7 , 0 , 0 , 0 ] [ 0 , 0 , 0 , 0 , 4 , 5 , 7 , 0 , 0 ] [ 0 , 0 , 0 , 1 , 0 , 0 , 0 , 3 , 0 ] [ 0 , 0 , 1 , 0 , 0 , 0 , 0 , 6 , 8 ] [ 0 , 0 , 8 , 5 , 0 , 0 , 0 , 1 , 0 ] [ 0 , 9 , 0 , 0 , 0 , 0 , 4 , 0 , 0 ] sudoku: 800000000 003600000 070090200 050007000 000045700 000100030 001000068 008500010 090000400 ans: 8 1 2 | 7 5 3 | 6 4 9 9 4 3 | 6 8 2 | 1 7 5 6 7 5 | 4 9 1 | 2 8 3 - - - - - - + - - - - - - - + - - - - - - 1 5 4 | 2 3 7 | 8 9 6 3 6 9 | 8 4 5 | 7 2 1 2 8 7 | 1 6 9 | 5 3 4 - - - - - - + - - - - - - - + - - - - - - 5 2 1 | 9 7 4 | 3 6 8 4 3 8 | 5 2 6 | 9 1 7 7 9 6 | 3 1 8 | 4 5 2 flag = 11230A2CD3C31CA32E0D707D38E0743531F80F726C1D133B3A914E2F034B1D63BB17F34428E2A31B038C25E0FA2BF2301053752062AA16E20A2FC1971730E90823D01A724B0CA19B0652811541480B80943AE27E13122C30C120 |
[培训]科锐逆向工程师培训 48期预科班将于 2023年10月13日 正式开班