跳过正文
  1. Writeups/

2024春秋杯网络安全联赛 Reverse

·2040 字·10 分钟·
CTF Reverse
目录

夏季赛
#

HardSignin
#

查到了UPX壳,发现是魔改过的,不能直接脱。在010editor中看到upx壳的节区名UPX被改了,改回来后可以直接脱壳。

hardsign-unpack

tlscallback_flower

32位程序,进行反汇编,又看到很多花指令。主要在TlsCallback的几个函数里面,而且这几个函数本身也常用来反调试。

先patch掉所以花指令。但是发现main函数依然不能正常反汇编成指令。这里猜测可能有SMC代码自解密。查看TlsCallback_0,能够看到它把main函数的前170个字节异或了0x66加密。用idaPython脚本解密一下。

hard-flower

tlscallback_0_smc

import idc
import idaapi
import idautils

def smc_xorenc(src_addr,dst_addr,key):
    length = (dst_addr-src_addr) if (dst_addr>src_addr) else 0
    for offset in range(length):
        addr = src_addr + offset
        value = idc.get_wide_byte(addr)
        enc_value = value^key
        ida_bytes.patch_byte(addr,enc_value)

if __name__ == "__main__":
    smc_xorenc(0x401890,0x401890+170,0x66)

重建 main 函数,F5查看伪码。除了常规的输入输出,加密函数是 sub_4016B0

继续跟踪该函数,分析加密流程:先对输入进行一次base64,之后每8位进行一次RC4(16轮),最后进行一次TEA。后面两次加密的key在 TlsCallback_2 中用随机数生成,种子已知。在 TlsCallback_1 中发现base64的编码表被打乱了顺序。属于魔改的base64。

编写脚本进行解密。思路是先还原编码表。之后解密TEA和RC4,得到编码后的字符串。在Cyberchef上进行最后的解码。

hard-cyber

#include<stdio.h>
#include<stdlib.h>
void TEAdecrypt(int a1, unsigned char* a2, unsigned char* a3) 
{ 
    for (int i = 0; i < 16; i += 2) {
        unsigned int v7 = *(unsigned int*)(a2 + 4 * i);
        unsigned int v6 = *(unsigned int*)(a2 + 4 * i + 4);
        int v5 = 0x9E3779B9 * a1; // 0x9E3779B9 是 1640531527
        for (int j = 0; j < a1; ++j) {
            v6 -= ((v7 + ((v7 >> 5) ^ (v7 << 4))) ^ (v5 + *(unsigned int*)( a3 + 4 * ((v5 >> 11) & 3))));
            v5 -= 0x9E3779B9;
            v7 -= ((v6 + ((v6 >> 5) ^ (v6 << 4))) ^ (v5 + *(unsigned int*)( a3 + 4 * (v5 & 3))));
        }
        *(unsigned int*)(a2 + 4 * i) = v7;
        *(unsigned int*)(a2 + 4 * i + 4) = v6;
    }
}
void RC4decrypt(unsigned char *a1,int a2,unsigned char *key,int length)
{
    unsigned char s_box[256],v6[256],temp,t;
    for ( int i = 0; i < 256; ++i )
        s_box[i] = i;
    for ( int j = 0; j < 256; ++j )
    {
        v6[j] = key[j % length];
    }
    int v4 = 0, v1 = 0, v2 = 0;
    for ( int k = 0; k < 256; ++k )
    {
        v4 = (v6[k] + v4 + s_box[k]) % 256;
        temp = s_box[k];
        s_box[k] = s_box[v4];
        s_box[v4] = temp;
    }
    for ( int m = 0; m < a2; ++m)
    {
        v1 = (v1 + 1) % 256;
        v2 = (v2 + s_box[v1]) % 256;
        t = s_box[v1];
        s_box[v1] = s_box[v2];
        s_box[v2] = t;
        a1[m] ^= s_box[(s_box[v1] + s_box[v2]) % 256];
    }
}

char table[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
unsigned char data[] =
{
  0x59, 0x1B, 0xFD, 0xB4, 0x6B, 0xB8, 0xBE, 0xD9, 0xB3, 0xD3, 
  0x77, 0xD6, 0xF0, 0x65, 0x5F, 0x18, 0xA0, 0x9D, 0x3A, 0x53, 
  0x6D, 0x4A, 0x7B, 0x26, 0x74, 0x3A, 0x9C, 0x4E, 0x20, 0x43, 
  0x19, 0xD8, 0x72, 0xED, 0x95, 0xB5, 0x9C, 0x05, 0x22, 0x56, 
  0xCB, 0x7A, 0x11, 0x91, 0x9F, 0x7A, 0xBC, 0x0C, 0x4A, 0x69, 
  0x6D, 0xCE, 0x3D, 0xB4, 0xAB, 0x29, 0x61, 0xFA, 0x62, 0x32, 
  0xB4, 0xEC, 0x4C, 0xB6
};
int main()
{
    srand(0x114514u);
    int v6,v4;
    unsigned char key1[17],key2[17];
    for ( int i = 0; i < 100; ++i )
    {
        v6 = rand() % 64;
        v4 = rand() % 64;
        char temp = table[v6];
        table[v6] = table[v4];
        table[v4] = temp;
    }
    printf("%s\n",table);
    srand(0x1919810u);
    for (int i = 0; i<0x10; ++i )
    {
        key1[i] = rand() % 255;
        key2[i] = rand() % 255;
    }
    TEAdecrypt(0x64,data,key2);
    RC4decrypt(data,64,key1,0x10);
    printf("%s",data);
    return 0;
}
//4yZRiNP8LoK/GSA5ElWkUjXtJCz7bMYcuFfpm6+hV0rxeHIdwv32QOTnqg1BDsa9
//C+vFCnHRGPghbmyQMXvFMRNd7fNCG8jcU+jcbnjRJTj2GTCOGUvgtOS0CTge7fNs02@

snack ( 复现 )
#

一个贪吃蛇游戏。运行发现是用pygame开发的,说明程序经过python打包,使用pyinstxtractor解包。

snack_unpack

这里的解包实际上不算成功。由于游戏程序的python版本和本地版本不符,所以pyz没有解压而是直接跳过。复现到后面发现需要用到 pyz_extracted 里面的库。这里更换python版本重新解包。

snack_pyz

之后尝试用uncompyle6进行反编译,但是出了问题。一开始怀疑pyc文件里有花指令一类的,后面换成了pycdc引擎结果成功了。估计问题还是在版本上

反编译后的代码不多,加密逻辑是魔改的RC4,而且程序本身就有解密代码,直接copy下来用。(反编译生成的代码语法有一点小问题,s盒的交换还有后面列表推导式不能直接运行,需要改一下)

解密密钥在自定义的库key中,这个就是上面提到的 pyz_extracted目录下的 key.pyc,反编译就能看到key

key_bytes = b"V3rY_v3Ry_Ez"

def initialize(key):
    key_length = len(key)
    S = list(range(256))
    j = 0
    for i in range(256):
        j = (j + S[i] + key[i % key_length]) % 256
        S[i],S[j] = S[j],S[i]
    return S

def generate_key_stream(S, length):
    i = 0
    j = 0
    key_stream = []
    for _ in range(length):
        i = (i + 1) % 256
        j = (j + S[i]) % 256
        S[i],S[j] = S[j],S[i]
        key_stream.append(S[(S[i] + S[j]) % 256])
    return key_stream

def decrypt(data, key):
    S = initialize(key)
    key_stream = generate_key_stream(S, len(data))
    decrypted_data = [ i ^ data[i] ^ key_stream[i] for i in range(len(data))]
    return decrypted_data


data = [101,97,39,125,218,172,205,3,235,195,72,125,89,130,103,213,
    120,227,193,67,174,71,162,248,244,12,238,92,160,203,185,155]
decrypted_data = decrypt(bytes(data), key_bytes)
print(bytes(decrypted_data))

BEDTEA ( 复现 )
#

反编译main函数的加密部分,先发现了两个反调试:一个是常规的IsDebuggerPresent() 调用API,结果保存到dword_404010,和后面加密过程有关系;另一个是用时间戳,两次调用system_clock::now 判断间隔时间。二者都可以在动调时改寄存器绕过。

bdtea_logic

跟踪输入v15,定位到sub_401E80。跟进分析,函数分成两个部分:先修改程序的一段数据作为密钥(参考官方wp,算法是斐波那契);后面是魔改的TEA,左移右移、delta还有轮数都改了。反编译的结果不太好分析,从汇编看比较清晰。生成密钥的初始值就是第一次反调试时得到的dword_404010

bdtea_asm

回到主函数,sub_401770sub_401560两个函数这里踩了坑。一开始以为是没有实际功能的库函数一类的,看了官方wp才知道是二叉树的前序遍历和后序遍历。其实看到输入v17参数传进去,就应该猜到是加密的一部分。这里是把加密数据逆序了。

后面调用了SSE指令集的xor指令,还额外多异或了一些无关数据。xor的值是固定的,直接写脚本即可。

#include<stdio.h>
void tea_decry(unsigned int *data, unsigned int *key)
{
    unsigned int d1 = data[0], d2 = data[1];
    unsigned int delta = 0x9e3449b8, number = 0x987e55d0;
    for (int i = 0; i < 22; i++)
    {
        d2 -= ((d1<<5) + key[2]) ^ ((d1>>4) + key[3]) ^ (d1 + number);
        d1 -= ((d2<<5) + key[0]) ^ ((d2>>4) + key[1]) ^ (d2 + number);
        number -= delta;
    }
    data[0] = d1;
    data[1] = d2;
}

int main()
{
    unsigned int key[] = {3,5,8,13}, key2[] = {21,34,55,89}, key3[] = {144,233,377,610};
    unsigned char datac[] ={
    0x76, 0x71, 0x9D, 0xE7, 0x70, 0x77, 0x3F, 0xA3, 
    0x02, 0xF1, 0x8D, 0xC9, 0x02, 0xC6, 0xA2, 0x4B,
    0xBA, 0x19, 0x56, 0x05, 0xF2, 0x89, 0x5E, 0xE0 };
    unsigned char *fd = datac,*bd = datac+23 ,tmp;
    for(int i = 0; i < 24; i ++) 
    {
        datac[i] ^= 0x33;
    }
    while(fd < bd)
    {
        tmp = *fd;
        *(fd++) = *bd;
        *(bd--) = tmp;
    }
    unsigned int *data = (unsigned int *)datac;
    tea_decry(data, key);
    tea_decry(data+2, key2);
    tea_decry(data+4, key3);
    printf("%s\n",(char*)data);
    return 0;
}

冬季赛 Day1
#

ezre
#

自定义的md5,利用openssl库的md5函数,对程序text段的1204个字节取hash。实际上后面将近300个字节是未定义的,这一部分取0x0即可。通过动调查看内存也可以确定。

在linux下动调发现MD5的结果有时会变,但是这一部分应该与输入无关,也就是固定的才对。赛后意识到是断点导致了内存中指令机器码发生变化。(应该考虑硬件断点,或者直接dump求MD5)

按照正常的逻辑,主函数用MD5值的前4个字节,取随机数逐字节异或。关键还是找到正确的hash值,一般来说通过动调dump内存是最优解,但是断点的因素导致这一方法比较困难。开多线程分段爆破也是有效的方法。

ezgo
#

有符号的Golang逆向,快速找到main部分。考虑到和c语言的差异,直接看反汇编CFG可能会比反编译伪码更清楚一些

main_init_0 用syscall 加载内核库中的IsDebuggerPresent 反调试,容易绕过。

对base64进行换表,并且编码结果再 xor 0x0c

TSRQPONMLKJIHGFEDCBAUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/

继续分析main_main,主要逻辑是提取zip文件的二进制数据进行解密,写入原文件中。程序要求输入4长度的字符串,经过魔改base64产生8字节密钥,参与后续的异或解密。

程序中并没有关于flag的更多信息,猜测是保存在zip文件中,解题的目标应该是找到正确解密该文件的密钥。参考zip文件格式固定位置的一些magic number,实际上已知部分明文,而且加密算法只是异或这种单字节的线性运算。可以考虑转换成若干个方程,用z3约束求解

magic number:

50 4b 03 04 14 00

感觉比较通用的方法是爆破,编码前密钥仅4字节且均为可见ascii字符,复杂度并不高。

编写脚本还原程序的逻辑来爆破,效果最好但是难度很高,前提是要对题目程序逆得很透彻,而且脚本编写不能出错。爆破之前也要简单估计一下字符范围,密钥空间等,避免大规模的爆破。

ko0h
#

算是签到题吧,经典inline hook并且加了花

交叉引用 VirtualProtec t定位到 Tlscallback,找到hook后真正的主函数,逻辑是魔改RC4

注意到有反调试,在其附近还能发现修改了RC4 key

data = [
    0x18,0x9c,0x47,0x3d,0x3b,0xe1,0x29,0x27,0x9f,0x34,0x83,0xd5,0xed,0xb5,
    0x6e,0x59,0x7f,0xde,0x47,0xd7,0x65,0x3f,0x7a,0x33,0x5b,0x64,0xb6,0xfa,
    0x94,0x55,0x87,0x42,0x20,0x06,0x0c,0x69,0xfe,0x72,0xa9,0xe4,0xd1,0x7c
    ]

key = b"DDDDAAAASSSS"
length = len(key)
S = [m for m in range(256)]
T = [key[n % length] for n in range(256)]

j = 0
for i in range(256):
    j = (j + S[i] + T[i]) % 256
    S[i],S[j] = S[j],S[i]

i = j = t = 0
for k in range(len(data)):
    i = (i + 1) % 256
    j = (j + S[i]) % 256
    t = (S[i] + S[j]) % 256
    S[i],S[j] = S[j],S[i]
    data[k] = (S[t] + data[k]) % 256

print(bytes(data))

ezvm
#

加密部分勉强能看出来是变种XTEA,但是魔改得太狠了。

vm使用 switch-case 实现,比较常规,字节码的长度也不算多。纯静态手撕的话应该是问题不大的。bytecode以4字节为单位,有一些冗余。

B2 00 00 00 00 00 00 00 
C4 00 00 00 04 00 00 00 
B2 00 00 00 00 00 00 00 
C5 00 00 00 06 00 00 00 
D7 00 00 00 
F4 00 00 00 
E2 00 00 00 
D8 00 00 00 03 00 00 00 
C6 00 00 00 
A1 00 00 00 
A2 00 00 00 
B3 00 00 00 00 00 00 00 
C4 00 00 00 04 00 00 00 
B3 00 00 00 00 00 00 00 
C5 00 00 00 06 00 00 00 
D7 00 00 00 
F4 00 00 00 
E1 00 00 00 
D8 00 00 00 03 00 00 00 
C6 00 00 00 
A3 00 00 00 
66 00 00 00

实际分析时发现一个 opcode 对应很多条汇编指令,导致 opcode 的含义比较模糊,而且各个 case 之间也会共享局部变量,所以各个字节码指令之间独立性不高,看起来更像是一段正常代码被拆成几部分,有一种控制流平坦化的感觉。

尝试在运行时trace,恢复出vm的执行流程,可以看出加密有多轮循环。

在trace结果中筛选出 switch 相关的部分。switch 本身通过跳转表实现,edx 控制跳转的地址,也就实现 case 的功能。

vmtrace

结合伪码可以还原出加密整个流程。还缺一些运算用到的操作数,需要回到bytecode中确认一下

另外 andxor 两个函数做了一点混淆,需要留意

~(~a2 | ~a1); 				// and
~(a2 & a1) & ~(~a2 & ~a1);	// xor

也可以尝试条件断点或者hook switch结构,效果可能比trace要好,这道题就不深究了。

32轮,delta = 0x20252025, 其余魔改的点参考脚本

#include<stdio.h>
#include<string.h>

unsigned char box[] =
{
	0xa4, 0xc4, 0x04, 0xce, 0x14, 0x95, 0xe9, 0x11, 
	0x31, 0x18, 0xb6, 0xb0, 0x01, 0x26, 0x24, 0x6a, 
	0x7b, 0x12, 0xcb, 0x67, 0xdb, 0xf8, 0xd2, 0x7e, 
	0x9d, 0xd0, 0x0c, 0x5f, 0x82, 0x21, 0x87, 0x83, 
	0x86, 0x7c, 0xc2, 0x9f, 0x29, 0xca, 0xbf, 0x49, 
	0xde, 0x4e, 0xcd, 0x62, 0x53, 0xbe, 0xa7, 0x03, 
	0x2f, 0xb5, 0xab, 0x94, 0xcc, 0x2e, 0x1d, 0xf3, 
	0x36, 0x10, 0xba, 0xd7, 0x13, 0x35, 0xe5, 0xb3, 
	0x81, 0x1a, 0xa0, 0xe7, 0x25, 0x75, 0xaf, 0x51, 
	0x43, 0x5c, 0x50, 0x48, 0xd8, 0xa3, 0x3f, 0x71, 
	0x7a, 0xc7, 0xc6, 0x90, 0xb1, 0xbb, 0xfa, 0xdd, 
	0xb9, 0xf6, 0xa9, 0xb7, 0x64, 0x38, 0xdf, 0xe0, 
	0x08, 0xb2, 0x77, 0x33, 0x5b, 0x02, 0x5e, 0x79, 
	0x61, 0x07, 0x69, 0x23, 0x57, 0x4a, 0xfd, 0xc0, 
	0x2b, 0xa1, 0xd1, 0x28, 0x09, 0x6f, 0x80, 0x55, 
	0xfe, 0x42, 0xe3, 0x47, 0x44, 0xe1, 0xff, 0xbc, 
	0x7d, 0x8b, 0x9a, 0x60, 0xad, 0x97, 0xfb, 0x8d, 
	0xd6, 0xac, 0x1e, 0x0f, 0x45, 0xea, 0xf5, 0x4b, 
	0x2d, 0x3b, 0x22, 0x1c, 0x5a, 0x72, 0x46, 0xc3, 
	0xe4, 0x5d, 0xda, 0x92, 0x9b, 0x0a, 0xbd, 0x99, 
	0x85, 0x34, 0x73, 0xa5, 0x56, 0x37, 0x4c, 0x16, 
	0x84, 0xa2, 0xb4, 0x6d, 0x54, 0xe6, 0xc1, 0x1f, 
	0x17, 0x3d, 0x88, 0xf7, 0x15, 0x58, 0xef, 0x4d, 
	0xee, 0x89, 0x68, 0x59, 0xb8, 0x20, 0xe8, 0xdc, 
	0xc9, 0x91, 0xfc, 0xd5, 0xc8, 0x41, 0x9e, 0x76, 
	0x78, 0x32, 0x19, 0x66, 0x65, 0x39, 0x6b, 0xc5, 
	0x52, 0x27, 0xa8, 0x06, 0x8e, 0xa6, 0x0d, 0x98, 
	0x8c, 0xf9, 0x05, 0x1b, 0x40, 0x8f, 0x4f, 0x3c, 
	0xeb, 0x70, 0xd9, 0x63, 0xd3, 0xe2, 0x2c, 0xf0, 
	0x93, 0x3a, 0xf4, 0x00, 0xf2, 0xaa, 0x7f, 0x2a, 
	0x30, 0xec, 0x6c, 0x74, 0x6e, 0xf1, 0x0e, 0x3e, 
	0xed, 0x96, 0xae, 0x8a, 0xcf, 0x0b, 0x9c, 0xd4
};


unsigned int substitute(unsigned int data)
{
	unsigned char *bytes = (unsigned char *)&data;
	for(int i = 0; i < 4; i++)
	{
		for(int j = 0; j < 256; j++)
		{
			if(bytes[i] == box[j])
			{
				bytes[i] = j;
				break;
			}
		}
	}
	return data;
}

void modify_xtea_decry(unsigned int *data, unsigned int *key)
{
	unsigned int round = 32;
    unsigned int d1 = data[0], d2 = data[1];
    unsigned int delta = 0x20252025, number = delta * round;
    for (int i = 0; i < round; i++)
    {
        d2 -= ( ((d1<<4) ^ (d1>>6) ^ 66) + d1) ^ (number + key[(number>>7) & 3]) ^ 3;
        number -= delta;
        d1 -= ( ((d2<<4) ^ (d2>>6) ^ 66) + d2) ^ (number + key[number & 3]) ^ 3;
    }
    data[0] = d1;
    data[1] = d2;
}

int main()
{
	unsigned int data[] = 
	{
		0x83845981,0x34402115,0xFB1F53D2,0x547564C9,0x3B42FCC6,
		0x2B67FCDE,0x675AB09C,0x1D47F41A,0x876D3272,0x734D7D95
	};
	unsigned int key[] =
	{
		2,0,2,5
	};

	for(int k=0;k<10;k+=2)
	{
		data[k] = substitute(data[k]);
	}
	for(int i=0;i<5;i++)
	{
		modify_xtea_decry(data+2*i,key);
	}
	printf("%s",(char*)data);
	return 0;
}

冬季赛 Day2
#

skip#

apk反编译,发现在native层检验用户名,java层检查password,二者合起来是flag

对于java层的加密,结合函数名和反编译代码可以分析出是 skip32 ( skipjack ) 算法。github能找到对应解密脚本

之后分析native的 libskip.so,只有一个加密函数。问一下DeepSeek分析出是DES算法。但是S盒经过修改,需要手动提取出来再解密。

skipnative

另外还要注意 JNI_onlode 反射修改了java层加密的ftable。这部分通过c++异常处理调用,看反编译伪码不容易发现,隐蔽性比较强,但是 其中std::runtime_error 也是有所提示。可以用frida hook出修改的结果。

Nu1tka
#

小众而高端的python打包工具。通过DIE可以查出经过nuitka打包

先尝试github上找到的解包工具 nuitka-extractor

nu1tka_extra

成功解包后有一个同名的exe文件以及几个动态库

参考官方wp,该程序会创建新进程并执行。主函数中可以找到 CreateProcessW 这个API,在其参数 ApplicationName 地址处下硬件断点,找到进程对应的程序路径。不难发现该路径下临时文件夹中的文件和上述工具解包的结果相同,执行的是解包后的同名exe。(也就是说目标程序及其依赖库作为原程序的资源,在运行时才解包执行。继续动调发现主函数执行过后这些资源被删除)

冬季赛 Day3
#

easyasm
#

入口点0x4c刚好是 start 函数。加密逻辑是逐字节异或再检验,一开始调用的 sub_84C0 修改了异或用的key

起初静态分析修改key的算法,看起来很像冒泡排序,但汇编功力不够,不好直接确定

用DOSBOX进行动态调试。没有折腾明白DOSBOX-X的debugger怎么用,于是就拿低配一点的debug进行分析。

单步跳过直到 call sub_84C0 后面(貌似不能打断点?)dump下来 key 修改后对应的内存

dosbox_dump

结合脚本进行解密

check_data = [0x44, 0x7C, 0x43, 0x72, 0x1D, 0x72, 0x74, 0x41,
    0x05, 0x14, 0x19, 0x1A, 0x19, 0x0F, 0xF5, 0x10, 0xAE, 0x18, 
    0x6D, 0x01, 0x10, 0x56, 0x00, 0x1E, 0x26, 0x71, 0x65, 0x73, 
    0x78, 0x72, 0xEB, 0x72, 0x52, 0x06, 0xAA, 0xBB, 0xA3, 0xA4,
    0x1B, 0xFC, 0xC7, 0x82]
encrypted_key = [0x22, 0x10, 0x22, 0x15, 0x66, 0x16, 0x11, 0x20,
    0x30, 0x20, 0x21, 0x22, 0x2c, 0x22, 0xcc, 0x22, 0xcc, 0x2c,
    0x40, 0x30, 0x21, 0x33, 0x66, 0x33, 0x44, 0x40, 0x50, 0x40,
    0x55, 0x41, 0x88, 0x42, 0x33, 0x60, 0x99, 0x88, 0xc2, 0xc2,
    0x22, 0xcc, 0xff, 0xff]
for i in range(42):
    check_data[i] ^= encrypted_key[i]
print(bytes(check_data))

ooooore
#

脚本去花

# ida-7.5 idapython 
import idc
import idautils

def nop(addr, endaddr):
    while addr <= endaddr:
        ida_bytes.patch_byte(addr, 0x90)
        addr += 1

def patch_junkcode(cur_addr,end_addr,pattern,nop_startoff,nop_endoff):
    while cur_addr<end_addr:
        cur_addr = idc.find_binary(cur_addr,SEARCH_DOWN,pattern)
        if cur_addr == idc.BADADDR:
        	break
        nop(cur_addr + nop_startoff,cur_addr + nop_endoff)
        print("patch address: " + hex(cur_addr))
        cur_addr = idc.next_head(cur_addr)
        

start_address = 0x1080
end_address = 0x7390

pattern1 = "0F 85 06 00 00 00 0F 84 01 00 00 00 21"  # jz jnz
pattern2 = "E8 00 00 00 00 48 83 04 24 08 C3 E8" # call ret 
pattern3 = "E9 00 00 00 00" # jmp $+5

patch_junkcode(start_address,end_address,pattern1,0,12)
patch_junkcode(start_address,end_address,pattern2,0,11)
patch_junkcode(start_address,end_address,pattern3,0,5)
  • jnz jz

junkcode1

  • call ret

junkcode2

jmp $+5 貌似也会干扰反编译,一并patch掉即可

修复后重新反编译(发现疑似控制流平坦化,d810处理效果一般)

逻辑是比较简单的单字节异或,可以动调验证

和谐
#

hap鸿蒙逆向,分析思路和apk差不多。

先改后缀为zip解压,找到其中的方舟字节码,即etc\modules.abc。使用 abc-decompiler反编译。

核心代码在Pages部分,只需要分析其中Index这个页面对应的类

按照一般逆向题目的逻辑,加密之前先输入flag,所以找字符串输入函数或者控件。发现一个匿名函数中的 TextArea 符合要求

hexieabc1

向前回溯,发现 initialRender 这个方法调用该匿名函数来渲染控件。再次交叉引用来到主函数func_main,其中调用了构造函数 Index 进行初始化(并且储存了(key?密文?)

一些API和库函数可以在鸿蒙官网查询:HarmonyOS NEXT开发文档

接下来分析输入flag后的加密逻辑。先调用方法 d 检查flag长度和格式,之后调用方法 g 进行加密

(官方wp:魔改SM4)

easyvm
#

Cython实现的stack vm,通过py脚本交互。好在保留符号,逆起来应该轻松点

由于是so库,在Linux python 3.8环境下尝试运行,用 helpdir 输出一些信息

pydvm_help

pydvm_help2

看出有一个 StackVM 类,大部分类方法是vm用到的指令handler。

继续动态分析,尝试函数级hook,直接修改类属性发现没有写权限。

from challenge import StackVM
func_add = StackVM.__dict__["vm_add"]

def hook_add():
    print("Call add")
    func_add()
    
StackVM.__dict__["vm_add"] = hook_add
#TypeError: 'mappingproxy' object does not support item assignment

不过可以考虑在子类中重写方法,之后运行虚拟机,实现类似trace的功能

from challenge import StackVM

null = ""
func_map = StackVM.__dict__

class hookedVM(StackVM):
    def vm_push(self,val):
        print("[Call Method] {} {}".format("vm_push",val))
        func_map["vm_push"](self,val)
        print(self.stack)
    def vm_pop(self):
        print("[Call Method] {} {}".format("vm_pop",null))
        func_map["vm_pop"](self)
        print(self.stack)
        
    def vm_add(self):
        print("[Call Method] {} {}".format("vm_add",null))
        func_map["vm_add"](self)
        print(self.stack)
    def vm_and(self):
        print("[Call Method] {} {}".format("vm_and",null))
        func_map["vm_and"](self)
        print(self.stack)
        
    def vm_lsh(self,val):
        print("[Call Method] {} {}".format("vm_lsh",val))
        func_map["vm_lsh"](self,val)
        print(self.stack)
    def vm_rsh(self,val):
        print("[Call Method] {} {}".format("vm_rsh",val))
        func_map["vm_rsh"](self,val)
        print(self.stack)
        
    def vm_read(self):
        print("[Call Method] {} {}".format("vm_read",null))
        func_map["vm_read"](self)
        print(self.stack)
    def vm_print(self):
        print("[Call Method] {} {}".format("vm_print",null))
        func_map["vm_print"](self)
        print(self.stack)
    def vm_jmp(self):
        print("[Call Method] {} {}".format("vm_jmp",null))
        func_map["vm_jmp"](self)
        print(self.stack)
        
    def vm_be(self):
        print("[Call Method] {} {}".format("vm_be",null))
        func_map["vm_be"](self)
        print(self.stack)
    def vm_bl(self):
        print("[Call Method] {} {}".format("vm_bl",null))
        func_map["vm_bl"](self)
        print(self.stack)

   
vm = hookedVM()

flag = input("Enter your flag: ")
vm.input_buffer = list(flag)
vm.vm_execute()

运行几次观察规律,大概推测出 vm_readinput_buffer 中读取字符,之后通过 vm_be 和压栈的数字进行比较,不相等直接 vm_jmp 跳转到错误分支,用 vm_print 逐字节输出信息。

pydvm_trace

整个过程都是单字节加密,而且每个字符加密后立即比较。可以考虑多次执行,逐个爆破字符。

测试爆破脚本的时候踩了两个坑:

  • 对于同一个vm对象只能调用一次 vm_execute 方法,如果重复调用则从第二次开始,只提示 "program stop" 而不运行bytecode。(猜测是有一个类似 rip 的全局变量指示字节码的地址,运行过一次vm之后这个变量没有重置)
  • 栈上数据应当从后向前读取。vm执行到后面的时候 pop 没有严格和 push 对应,导致堆栈不平衡。栈前面遗留了一些垃圾数据。
from challenge import StackVM

class solveVM(StackVM):
    buffer = []
    
    def vm_print(self):
        pass
        
    def vm_be(self):
        current_char = self.stack[-3]
        target_char = self.stack[-2]
       
        if current_char == target_char:
            self.buffer = self.input_buffer
        else:
            self.buffer = self.input_buffer[:-1]
            
        StackVM.__dict__["vm_be"](self)

        
flag = []        
digist_table = "0123456789"
lower_table = "abcdefghijklmnopqrstuvwxyz"
supper_table = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
brute_table = digist_table + lower_table + supper_table + "_-{}"

while True:
    for char in brute_table:
        vm = solveVM()
        vm.input_buffer = flag
        vm.input_buffer.append(char)
        vm.vm_execute()
        flag = vm.buffer
        
    if len(flag) > 0:
        if flag[-1] == "}":
            break
            
print("".join(flag))
#flag{222cccf2-71b6-477b-bcea0f230e0e}

相关文章

第十七届CISCN初赛&华东北分区赛
·509 字·3 分钟
CTF AWDP Reverse Pwn
writeup and reproduction of 17th CISCN, reverse & pwn
LitCTF2024 Reverse
·163 字·1 分钟
CTF Reverse
reverse writeup of LitCTF(NSS) 2024
HGAME2024 Revserse
·1327 字·7 分钟
CTF Reverse
reverse writeup of Hgame2024