Loading... # 阿里CTF2026-License 记录下第一次搞协议逆向,很遗憾比赛过程中卡在最后一步了 总体协议流程如下 1. 数据包=长度(4字节)+nonce(12字节)+数据流(长度-12字节) 2. 数据流做AES-GCM解密 3. zstd解压缩 4. base64解码 5. protobuf解析出来四个字段,enc_data、password、salt、sha256_hash 6. AES-160解密(魔改了SBOX、mixcolumn、加密解密互换了,同时是CBC模式加PKC#7),密钥是password、salt做PBKDF2-HMAC-SHA256得到48字节,前28字节作为密钥,后20字节作为iv 7. 解密后是一个json格式字符串,要求包含license_code和sign字段,license_code就是license启动时随机生成的字符串,sign是RSA4096签名(比赛分析到这里了,签名没分析出来,题目应该是给了个d,需要用密码学攻击还原e,但实际上e就是65537) 8. 上面全部通过读取和打印FLAG环境变量 ## ELF加载 直接分析license可以发现他和build_token建立了通信  运行过程中打印了`license code: xxxx-uuid`这样的字符串,但并没有在license文件里找到,猜测是从build_token发送elf回来加载到主elf内存里执行代码 直接上调试,ida里按照run.sh里设置`--no-redirect -c 127.0.0.1:12345`,本地跑`./build_token -p 'r&FGW9RpqTc*aqof' -s -l 0.0.0.0:12345`,从main函数return 0开始调试  刚开始可以看到retn后来到了新的区域  慢慢调试可以发现有一些脱壳的感觉,在解密一些数据代码,按照经验不停的跳过循环,最终可以发现来到一块非常大的函数  单步进入反编译后可以看到license、flag等字符串,说明这里才是真正的check逻辑  ## 数据包结构 开头部分是在生成随机的UUID,慢慢调试直到要求输入,发现下图红框位置要求输入数据,分别要求4、12长度(注意回车符也算输入)  第三处要求输入长度的正是第一处输入的数值-12(不太可控,因为手动输入只能输可打印字符),因此调试到这里时我手动在第三处输入前把要求长度修改掉,从而得以输入可控长度的测试数据,我们这里成为数据流 ## 数据流AES-GCM解密 数据包结构分析完后,下图中sub_403D10函数会返回CPU架构是否支持AVX并设置一个bool字节(随后很多if-else会根据这个值选择进入到的代码,这些分支代码逻辑是相同的,可以省略很多分析过程)。 我的CPU支持AVX,进入到下图红框中的数据初始化,AI分析可知是密钥扩展,但这里的密钥通过一定运算才能得到  所以直接来到最后最终轮密钥结果处,提取出来前32字节即可获得密钥为`f6778d8728d8f17ce8c5c81f45c3d5fd869ca851b7575be540776f4f26c1140d` if-else出来后发现对16个0字节做了加密  ~~~c++ void __fastcall sub_40C1B0(__m128i *_RDI, const __m128i *a2, _OWORD *a3) { _XMM0 = _mm_xor_si128(_mm_loadu_si128(a2), *_RDI); __asm { aesenc xmm0, xmmword ptr [rdi+10h] aesenc xmm0, xmmword ptr [rdi+20h] aesenc xmm0, xmmword ptr [rdi+30h] aesenc xmm0, xmmword ptr [rdi+40h] aesenc xmm0, xmmword ptr [rdi+50h] aesenc xmm0, xmmword ptr [rdi+60h] aesenc xmm0, xmmword ptr [rdi+70h] aesenc xmm0, xmmword ptr [rdi+80h] aesenc xmm0, xmmword ptr [rdi+90h] aesenc xmm0, xmmword ptr [rdi+0A0h] aesenc xmm0, xmmword ptr [rdi+0B0h] aesenc xmm0, xmmword ptr [rdi+0C0h] aesenc xmm0, xmmword ptr [rdi+0D0h] aesenclast xmm0, xmmword ptr [rdi+0E0h] } *a3 = _XMM0; } ~~~ 这种模式符合AES-256-GCM模式,里面包含2部分,CTR+GHASH随后的调试里都可以看到特征,比如下图里检查了数据流最后16字节是否等于一组结果  可以写一个代码来加密数据,结果放入数据包中的数据流部分,从而调试通过这部分AES解密 ## zstd解压缩 解密完的数据发现检查了大小,要求不小于13字节  在往下调试的过程中发现报错`Unknown frame descriptor`,搜索字符串可以定位一堆报错case ~~~c++ const char *__fastcall sub_7FFFF7D68AA6(unsigned __int64 a1) { int v1; // r8d const char *result; // rax v1 = 0; if ( a1 > 0xFFFFFFFFFFFFFF88LL ) v1 = -(int)a1; switch ( v1 ) { case 0: result = "No error detected"; break; case 1: result = "Error (generic)"; break; case 2: case 3: case 4: case 5: case 6: case 7: case 8: case 9: case 11: case 13: case 15: case 17: case 18: case 19: case 21: case 23: LABEL_38: result = "Unspecified error code"; break; case 10: result = "Unknown frame descriptor"; break; case 12: result = "Version not supported"; break; case 14: result = "Unsupported frame parameter"; break; case 16: result = "Frame requires too much memory for decoding"; break; case 20: result = "Data corruption detected"; break; case 22: result = "Restored data doesn't match checksum"; break; case 24: result = "Header of Literals' block doesn't respect format specification"; break; default: switch ( v1 ) { case 30: result = "Dictionary is corrupted"; break; case 32: result = "Dictionary mismatch"; break; case 34: result = "Cannot create Dictionary from provided samples"; break; case 40: result = "Unsupported parameter"; break; case 41: result = "Unsupported combination of parameters"; break; case 42: result = "Parameter is out of bound"; break; case 44: result = "tableLog requires too much memory : unsupported"; break; case 46: result = "Unsupported max Symbol Value : too large"; break; case 48: result = "Specified maxSymbolValue is too small"; break; case 49: result = "This mode cannot generate an uncompressed block"; break; case 50: result = "pledged buffer stability condition is not respected"; break; case 60: result = "Operation not authorized at current processing stage"; break; case 62: result = "Context should be init first"; break; case 64: result = "Allocation error : not enough memory"; break; case 66: result = "workSpace buffer is not large enough"; break; case 70: result = "Destination buffer is too small"; break; case 72: result = "Src size is incorrect"; break; case 74: result = "Operation on NULL destination buffer"; break; case 80: result = "Operation made no progress over multiple calls, due to output buffer being full"; break; case 82: result = "Operation made no progress over multiple calls, due to input being empty"; break; case 100: result = "Frame index is too large"; break; case 102: result = "An I/O error occurred when reading/seeking"; break; case 104: result = "Destination buffer is wrong"; break; case 105: result = "Source buffer is wrong"; break; case 106: result = "Block-level external sequence producer returned an error code"; break; case 107: result = "External sequences are not valid"; break; default: goto LABEL_38; } break; } return result; } ~~~ 搜索可知是Zstandard解码器报错,解码器在输入数据开头没有识别出合法的魔数(`28 B5 2F FD`,可以IDA搜索到多个比较),所以才报错  python有zstandard库可以直接帮我们压缩数据,把压缩完的数据传给AES-GCM再去构造数据包即可保证这里解压不在报错 ## Base64解码 这里非常明显,多处base64查表,而且上一步骤中解压的数据字符不是4的倍数会报错padding不对等等,确认这里在进行base64解码  所以只需要把我们对数据做一次base64编码再去zstd压缩、再去AES-GCM加密,最后构造包即可通过这部分 ## Protobuf解析 来到下图这里时会出现新的报错,结合AI分析以及代码里switch-case取字段的逻辑,可知是protobuf解析   sub_40B960里要求了wire必须等于2,一共四个字段`enc_data`、`password`、`salt`和`sha256_hash` 所以这里要构造符合protobuf的数据结构 ## AES-160解密 发现sha256加密  通过可控数据调试以及上一步骤中字段名,发现password和salt最先被读取并做了PBKDF2-HMAC-SHA256,iter正好是1000,生成了48字节  调试过程中到上图ROR8的位置时发现检查enc_data字段数据长度是否为20的倍数,不是就会跳转LABEL_80(ud2,也就是BUG()) 当我修改完enc_data长度为20倍数后,到下图时发现解密完的数据最后检查了最后1字节是否小于20,如果大于20会报错invalid padding  到这里可以分析出是某种对称加密(分组大小为20字节)+PKCS#7填充 回过头分析对称加密算法,下图中调试经过unk_409DC0后会发现off_6EA318指向了一个256字节大小的sbox  值为 ~~~python AES_SBOX = bytes.fromhex( "637C699066329A0E6441CBA99FFAD5AA6524F777371D83EB981A2A7DBD2502EEE" "5E7455029C4ECA7CCF05C4D1396A2099EFF5AA1C76FE9150C1BC5975614A5B620" "D62111700D7F4E4652354BA4C9011E310F2F17FCDB7430DE481C950653D36718F" "D2D1F7A8D8775B426E071A3825807D4BADAA8B5D99CCFF960D81200798904C2B8" "3C614276DF6CEA495462E8B3F50BF1287ED2CD23F28E80F836E3D722DDF34A2E5" "510C0B15943AC683FBB6DAFCAC638B973AEDCBC9DC3D14CFEA63B92E42B5BFB2C" "F6C1B25D8FEF78915F9472ED4088B7443427E16A0586C8938A7B8451E63D990A3" "3BF39038C086B3E8519CEB08BABA0E247BE4F5E9B57AD6E81163AD0F4" ) ~~~ 和标准AES不一样,但类似AES,搜索发现确实存在20字节分组的算法。调试发现unk_409DC0里面存在表生成的函数 ~~~c++ __int64 __fastcall sub_409DC0(volatile signed __int32 *a1, int a2, __int64 a3, __int64 a4) { __int64 result; // rax int v5; // ett int v6; // ecx unsigned int v7; // ebp int v8; // ecx unsigned __int32 v9; // ebp bool v10; // al __int64 v11; // [rsp+86h] [rbp-58h] BYREF __int64 v12; // [rsp+9Eh] [rbp-40h] __int64 v13; // [rsp+A6h] [rbp-38h] v13 = a4; v12 = a3; result = *(unsigned int *)a1; if ( a2 ) { do { while ( 1 ) { v6 = result & 3; if ( (unsigned int)(v6 - 2) < 2 ) break; if ( v6 != 1 ) return result; v7 = result; if ( (result & 4) == 0 ) { v7 = result | 4; result = (unsigned int)_InterlockedCompareExchange(a1, result | 4, 1); if ( (_DWORD)result != 1 ) continue; } v11 = 0LL; while ( *a1 == v7 && ((__int64 (__fastcall *)(__int64, volatile signed __int32 *, __int64, _QWORD, _QWORD, _QWORD, int))unk_4C70D6)( 202LL, a1, 137LL, v7, 0LL, 0LL, -1) < 0 && *(_DWORD *)((__int64 (*)(void))unk_4C4D28)() == 4 ) ; result = *(unsigned int *)a1; } v5 = result; result = (unsigned int)_InterlockedCompareExchange(a1, result & 4 | 1, result); } while ( v5 != (_DWORD)result ); v10 = v6 == 2; } else { LABEL_15: while ( 2 ) { v8 = result & 4; v9 = result; switch ( result & 3 ) { case 0LL: return result; case 1LL: while ( 2 ) { if ( v8 || (v9 |= 4u, result = (unsigned int)_InterlockedCompareExchange(a1, v9, 1), (_DWORD)result == 1) ) { v11 = 0LL; while ( *a1 == v9 && ((__int64 (__fastcall *)(__int64, volatile signed __int32 *, __int64, _QWORD, _QWORD, _QWORD, int))unk_4C70D6)( 202LL, a1, 137LL, v9, 0LL, 0LL, -1) < 0 && *(_DWORD *)((__int64 (*)(void))unk_4C4D28)() == 4 ) ; v9 = *a1; result = *a1 & 3; v8 = *a1 & 4; switch ( *a1 & 3 ) { case 0: return result; case 1: continue; case 2: goto LABEL_23; case 3: goto LABEL_14; } } goto LABEL_15; } case 2LL: LABEL_23: BUG(); case 3LL: LABEL_14: result = (unsigned int)_InterlockedCompareExchange(a1, v8 | 1, v9); if ( v9 != (_DWORD)result ) continue; v10 = 0; break; } break; } } LODWORD(v11) = 0; BYTE4(v11) = v10; (*(void (__fastcall **)(__int64, __int64 *))(v13 + 32))(v12, &v11); result = (unsigned int)_InterlockedExchange(a1, v11); if ( (result & 4) != 0 ) return off_6E9E98(202LL, a1, 129LL, 0x7FFFFFFFLL); return result; } __int64 __fastcall sub_41EBE0(__int64 a1) { __int64 i; // rbp _UNKNOWN **v3; // [rsp+0h] [rbp-148h] BYREF _UNKNOWN ***v4; // [rsp+8h] [rbp-140h] BYREF _OWORD v5[19]; // [rsp+10h] [rbp-138h] BYREF memset(v5, 0, 256); for ( i = 0LL; i != 256; ++i ) { if ( dword_6EA310 ) { v3 = &off_6EA010; v4 = &v3; sub_409DC0(&dword_6EA310, 1, (__int64)&v4, (__int64)&unk_6E95E8); } *((_BYTE *)v5 + i) = __ROL1__(*((_BYTE *)&off_6EA010 + i + 512), 3) ^ __ROL1__(*((_BYTE *)&off_6EA010 + i + 512), 1) ^ __ROL1__(*((_BYTE *)&off_6EA010 + i + 512), 4) ^ *((_BYTE *)&off_6EA010 + i + 512) ^ __ROL1__(*((_BYTE *)&off_6EA010 + i + 512), 2) ^ 0x63; } off_6E9ED8(a1, v5, 256LL); *(_QWORD *)(a1 + 256) = 10LL; *(_QWORD *)(a1 + 264) = 11LL; *(_QWORD *)(a1 + 272) = 12LL; *(_QWORD *)(a1 + 280) = 13LL; *(_QWORD *)(a1 + 288) = 14LL; *(_QWORD *)(a1 + 296) = 11LL; *(_QWORD *)(a1 + 304) = 11LL; *(_QWORD *)(a1 + 312) = 12LL; *(_QWORD *)(a1 + 320) = 13LL; *(_QWORD *)(a1 + 328) = 14LL; *(_QWORD *)(a1 + 336) = 12LL; *(_QWORD *)(a1 + 344) = 12LL; *(_QWORD *)(a1 + 352) = 12LL; *(_QWORD *)(a1 + 360) = 13LL; *(_QWORD *)(a1 + 368) = 14LL; *(_QWORD *)(a1 + 376) = 13LL; *(_QWORD *)(a1 + 384) = 13LL; *(_QWORD *)(a1 + 392) = 13LL; *(_QWORD *)(a1 + 400) = 13LL; *(_QWORD *)(a1 + 408) = 14LL; *(_QWORD *)(a1 + 416) = 14LL; *(_QWORD *)(a1 + 424) = 14LL; *(_QWORD *)(a1 + 432) = 14LL; *(_QWORD *)(a1 + 440) = 14LL; *(_QWORD *)(a1 + 448) = 14LL; *(_DWORD *)(a1 + 456) = 17170948; return a1; } __int64 __fastcall sub_41EE90(_QWORD **a1, __int64 a2) { __int64 v2; // r14 void (__fastcall **v3)(_BYTE *); // rbx char v5; // al __int64 v6; // rcx char v7; // si bool v8; // sf char v9; // al char v10; // si __int64 i; // rax _OWORD v12[15]; // [rsp+0h] [rbp-630h] BYREF __int128 v13; // [rsp+F0h] [rbp-540h] _OWORD v14[15]; // [rsp+100h] [rbp-530h] BYREF __int128 v15; // [rsp+1F0h] [rbp-440h] _OWORD v16[16]; // [rsp+200h] [rbp-430h] BYREF void (__fastcall **v17)(_BYTE *); // [rsp+308h] [rbp-328h] __int64 v18; // [rsp+310h] [rbp-320h] _BYTE v19[784]; // [rsp+320h] [rbp-310h] BYREF v3 = (void (__fastcall **)(_BYTE *))**a1; **a1 = 0LL; if ( !v3 ) BUG(); if ( *(_BYTE *)(a2 + 4) == 1 ) { ((void (*)(void))unk_404120)(); v18 = v2; v17 = v3; v15 = 0LL; memset(v14, 0, sizeof(v14)); memset(v12, 0, sizeof(v12)); v13 = 0LL; v5 = 1; v6 = 0LL; do { *((_BYTE *)v12 + v6) = v5; *((_BYTE *)v14 + (unsigned __int8)v5) = v6++; v7 = 2 * v5; v8 = v5 < 0; v9 = (2 * v5) ^ 0x8D; if ( !v8 ) v9 = v7; v10 = 2 * v9; v8 = v9 < 0; v5 = (2 * v9) ^ 0x8D; if ( !v8 ) v5 = v10; } while ( v6 != 255 ); HIBYTE(v13) = v12[0]; memset(v16, 0, sizeof(v16)); for ( i = 3LL; i != 258; i += 3LL ) { *((_BYTE *)&v15 + i + 14) = *((_BYTE *)v12 + (*((unsigned __int8 *)&v13 + i + 14) ^ 0xFFLL)); *((_BYTE *)&v15 + i + 15) = *((_BYTE *)v12 + (*((unsigned __int8 *)&v13 + i + 15) ^ 0xFFLL)); *((_BYTE *)v16 + i) = *((_BYTE *)v12 + (*((unsigned __int8 *)v14 + i) ^ 0xFFLL)); } ((void (__fastcall *)(_QWORD **, _OWORD *, __int64))unk_4C9CBF)(a1, v14, 256LL); ((void (__fastcall *)(_QWORD **, _OWORD *, __int64))unk_4C9CBF)(a1 + 32, v12, 256LL); ((void (__fastcall *)(_QWORD **, _OWORD *, __int64))unk_4C9CBF)(a1 + 64, v16, 256LL); return (__int64)a1; } else { (*v3)(v19); return off_6E9ED8(v3, v19, 768LL); } } ~~~ 交给AI分析可知生成了3张256字节表,用于方便后续AES计算,其中有log、exp、inv表。需要注意的是生成方式和标准不一样,标准xtime异或是0x1b,sub_41EE90里是异或了0x8d  最后出来后又在sub_41EBE0中生成了sbox(这里魔改了) 出来后qword_6EA498值为13,符合AES-160的NK+6(密钥是28字节224bit,NK=7)   此外在一轮轮调试数据还发现到mixcolumn时结果变了,检查发现数值和标准的不一样   到这里应该是全部的魔改点了。调试可以发现AES-160采用的是CBC模式,前面PBKDF2-HMAC-SHA256生成的48字节,前28字节作为密钥,后20字节作为iv。 最后还有一点,前面提到有padding所以应该是解密,但是上面的流程是按照加密算才和调试拿到的数据对应的,所以有可能是加密解密算法对调了下。 解密后会检查sha256加密后的值是否等于protobuf里解析出来的sha256_hash值(32字节)  ## 时间戳校验+RSA签名 前面步骤全部正确后会在最终check前再次进入一个很大的函数,整体AI分析了一遍可知是在做json数据解析,要求包含license_code和sign字段,license_code值等于最开始随机生成的UUID,而sign字段经过一定加密要求最后16字节等于UUID(去除`-`并转为字节) 通过观察报错定位到第一个是时间戳校验(`license expired`)  上图中计算了时间戳差值并检查是否小于60s 时间戳校验后有一大串常量数据(1024字节)  继续往下走发现从上面数据中初始化了512字节,之后跳转到一个处理hex的逻辑  其中v204是单一字符,取自v196字符串,查看发现正是我们设置的sign值。下图每次读取两个字符拼接为一字节  之后是一个while循环,逻辑仍然是处理hex值,转为字节存储到了v206  又提取了固定的512字节(来自上面常量)  接着进入到一个函数,传递了sign和512字节,AI分析是大整数取模运算,符合RSA特性  通过调试拿到输入输出值测试发现是在计算 ~~~python n = bytes.fromhex("B75815AF28D17CCE2ECA787C6004EC6F1A51AC14B5A7AF746C7B3DD1354517C3CF38B35BE17E64BAB76A47FEA92396DDA947CC268EE0DACC097911912AB64B4C572D7518003C8B24D9DF5E950D44FE9613805ABDC47EA1F693EB6B04D56A124522126770E9FC771B185EC44F308D57FAA6D3C67A585A5E1672F5F89FADB9B073E913166A99D5A3896BDC6430B1C4A5AECDA7FEFA15AEB41A37E76D61698FF36A2F1B2B0A0CBC1AA0AD068C8ECF9388558B0257335EADC4831397917CE1C9B5E2B033C6935F1B57A06BC830B3D03CC8C8D758A38CD8D85583435B3594A7599F1B692B9FBF0E98B388E6A96D20D6245EF5503F79693552987CCAEC2C86D481A45EEA1C573D33FA15109962E5C8D2A02A6923FB375C6B1F05FC4CDCCC17055AADBA17085FC8C22563DA4FFD05FEBD07A485B5B28C3203890FDABDDD6693C40FCECE05D4FEA9EE46F1447416B7FF4D914A6A5787917637977A3330659D8191CD102093F15ED4D2444E60A4950AE51EBF616721F8785F5656130CFABE2174DBB9F9E5121F8F10670EC0538465B283A02C187989E06C07E3BD3792C5E5E7C49752ADCCFA573DF668C90AB67C61CFC5E46D5CC387D51078D27ADBD8E3AE3CDA5C7A00E741B60E8DD1AD61D0F7633FC2E9F30A3BAC74897001B1AEB1B7C27A0C25A75620B8FD1374084F86186C56AF651FBEEE0DFEDA25FC110EFCD8BBB0C05222326BBF") n = int.from_bytes(n, "little") print(hex((1<<4096)%n-1)) ~~~ 到这之后基本确认是RSA-4096签名生成的sign,比赛时卡这里是因为n、d端序提取错了,导致RSA签名完的数据一直对不上调试拿到的数据 所以总结来说这里sign经过n、d(私钥)解密后最后16字节要等于uuid,所以我们需要用n、e(公钥)加密uuid作为sign值构造json ## 总结 到这里就是一步步往回逆向,具体流程如下 1. 远程连接获取license_code,构造json数据,包含license_code和sign字段,sign字段由uuid值做rsa加密生成 2. 得到的json数据做魔改后的AES-160加密,密钥和iv由后面的password、salt做PBKDF2-HMAC-SHA256加密生成 3. AES-160加密完的结果放到enc_data中,password、salt可以我们自己构造,sha256_hash值(hex)要等于json数据sha256加密的结果 4. 构造protobuf,四个字段 5. 对protobuf字节流Base64编码 6. 对Base64字符串做zstd压缩 7. 对zstd数据流做AES-GCM加密 8. 加密完的结果作为数据流,构造最终的数据包=长度(4字节)+nonce(12字节)+数据流(长度-12字节) 全流程代码如下 ~~~python import hashlib from base64 import b64encode import zstandard as zstd def zstd_compress(data): return zstd.compress(data) def xtime(a) -> int: a &= 0xFF return (((a << 1) ^ 0x8D) & 0xFF) if (a & 0x80) else ((a << 1) & 0xFF) def gf_mul(a, b) -> int: a &= 0xFF b &= 0xFF res = 0 for _ in range(8): if b & 1: res ^= a a = xtime(a) b >>= 1 return res & 0xFF # ---------------- S-box / inverse S-box (standard AES S-box) ---------------- AES_SBOX = bytes.fromhex( "637C699066329A0E6441CBA99FFAD5AA6524F777371D83EB981A2A7DBD2502EEE" "5E7455029C4ECA7CCF05C4D1396A2099EFF5AA1C76FE9150C1BC5975614A5B620" "D62111700D7F4E4652354BA4C9011E310F2F17FCDB7430DE481C950653D36718F" "D2D1F7A8D8775B426E071A3825807D4BADAA8B5D99CCFF960D81200798904C2B8" "3C614276DF6CEA495462E8B3F50BF1287ED2CD23F28E80F836E3D722DDF34A2E5" "510C0B15943AC683FBB6DAFCAC638B973AEDCBC9DC3D14CFEA63B92E42B5BFB2C" "F6C1B25D8FEF78915F9472ED4088B7443427E16A0586C8938A7B8451E63D990A3" "3BF39038C086B3E8519CEB08BABA0E247BE4F5E9B57AD6E81163AD0F4" ) INV_SBOX = bytearray(256) for i, v in enumerate(AES_SBOX): INV_SBOX[v] = i INV_SBOX = bytes(INV_SBOX) # ---------------- Rijndael-160 params ---------------- def rijndael_rounds(Nb, Nk) -> int: return max(Nb, Nk) + 6 # same as Rijndael # ---------------- Key schedule (uses your xtime/rcon field) ---------------- def rot_word(w) -> int: return ((w << 8) & 0xFFFFFFFF) | ((w >> 24) & 0xFF) def sub_word(w, sbox) -> int: return ( (sbox[(w >> 24) & 0xFF] << 24) | (sbox[(w >> 16) & 0xFF] << 16) | (sbox[(w >> 8) & 0xFF] << 8) | (sbox[(w >> 0) & 0xFF] << 0) ) & 0xFFFFFFFF def rcon(i) -> int: # i starts from 1, stored in MSB byte c = 1 for _ in range(i - 1): c = xtime(c) return (c << 24) & 0xFFFFFFFF def key_expansion(key, Nb, Nk, sbox): if len(key) != 4 * Nk: raise ValueError(f"key length must be {4*Nk} bytes for Nk={Nk}, got {len(key)}") Nr = rijndael_rounds(Nb, Nk) W_words = Nb * (Nr + 1) w = [0] * W_words # first Nk words (big-endian as in your code) for i in range(Nk): w[i] = int.from_bytes(key[4*i:4*i+4], "big") for i in range(Nk, W_words): temp = w[i - 1] if i % Nk == 0: temp = sub_word(rot_word(temp), sbox) ^ rcon(i // Nk) elif Nk > 6 and (i % Nk) == 4: temp = sub_word(temp, sbox) w[i] = (w[i - Nk] ^ temp) & 0xFFFFFFFF return w # ---------------- State helpers (Nb=5 => 4x5, column-major) ---------------- def bytes_to_state(block, Nb): if len(block) != 4 * Nb: raise ValueError("bad block size") s = [[0]*Nb for _ in range(4)] for c in range(Nb): for r in range(4): s[r][c] = block[4*c + r] return s def state_to_bytes(s, Nb): out = bytearray(4*Nb) for c in range(Nb): for r in range(4): out[4*c + r] = s[r][c] & 0xFF return bytes(out) def add_round_key(s, round_w, round_idx, Nb) -> None: base = round_idx * Nb for c in range(Nb): w = round_w[base + c] s[0][c] ^= (w >> 24) & 0xFF s[1][c] ^= (w >> 16) & 0xFF s[2][c] ^= (w >> 8) & 0xFF s[3][c] ^= (w >> 0) & 0xFF def sub_bytes(s, sbox, Nb) -> None: for r in range(4): for c in range(Nb): s[r][c] = sbox[s[r][c]] # 你对齐出来的是“右移”版本 def shift_rows(s, Nb) -> None: for r in range(1, 4): k = r % Nb row = s[r] s[r] = row[-k:] + row[:-k] def inv_shift_rows(s, Nb) -> None: for r in range(1, 4): k = r % Nb row = s[r] s[r] = row[k:] + row[:k] # ---------------- MixColumns (your matrix) + auto inverse ---------------- MIX_MAT = [ [4, 1, 6, 2], [2, 4, 1, 6], [6, 2, 4, 1], [1, 6, 2, 4], ] def gf_mat_inv_4x4(mat): # Gauss-Jordan over GF(2^8) with xor add and gf_mul # Build [mat | I] A = [[mat[r][c] & 0xFF for c in range(4)] + [1 if c == r else 0 for c in range(4)] for r in range(4)] def gf_inv(x) -> int: # brute-force inverse in GF(2^8) for this field x &= 0xFF if x == 0: raise ZeroDivisionError("no inverse for 0") for y in range(1, 256): if gf_mul(x, y) == 1: return y raise ZeroDivisionError("no inverse found (should not happen)") for col in range(4): # find pivot pivot = None for r in range(col, 4): if A[r][col] != 0: pivot = r break if pivot is None: raise ValueError("matrix not invertible") if pivot != col: A[col], A[pivot] = A[pivot], A[col] inv_p = gf_inv(A[col][col]) # scale pivot row for j in range(8): A[col][j] = gf_mul(A[col][j], inv_p) # eliminate other rows for r in range(4): if r == col: continue factor = A[r][col] if factor == 0: continue for j in range(8): A[r][j] ^= gf_mul(factor, A[col][j]) inv = [[A[r][4 + c] & 0xFF for c in range(4)] for r in range(4)] return inv INV_MIX_MAT = gf_mat_inv_4x4(MIX_MAT) def mix_single_column(col): a0, a1, a2, a3 = [x & 0xFF for x in col] out = [] for r in range(4): out.append( gf_mul(MIX_MAT[r][0], a0) ^ gf_mul(MIX_MAT[r][1], a1) ^ gf_mul(MIX_MAT[r][2], a2) ^ gf_mul(MIX_MAT[r][3], a3) ) return [x & 0xFF for x in out] def inv_mix_single_column(col): a0, a1, a2, a3 = [x & 0xFF for x in col] out = [] for r in range(4): out.append( gf_mul(INV_MIX_MAT[r][0], a0) ^ gf_mul(INV_MIX_MAT[r][1], a1) ^ gf_mul(INV_MIX_MAT[r][2], a2) ^ gf_mul(INV_MIX_MAT[r][3], a3) ) return [x & 0xFF for x in out] def mix_columns(s, Nb) -> None: for c in range(Nb): col = [s[r][c] for r in range(4)] mc = mix_single_column(col) for r in range(4): s[r][c] = mc[r] def inv_mix_columns(s, Nb) -> None: for c in range(Nb): col = [s[r][c] for r in range(4)] mc = inv_mix_single_column(col) for r in range(4): s[r][c] = mc[r] # ---------------- Block encrypt/decrypt (20B) ---------------- def rijndael160_encrypt_block(block20, key28, Nb = 5, Nk = 7): Nr = rijndael_rounds(Nb, Nk) w = key_expansion(key28, Nb, Nk, AES_SBOX) s = bytes_to_state(block20, Nb) add_round_key(s, w, 0, Nb) for rnd in range(1, Nr): sub_bytes(s, AES_SBOX, Nb) shift_rows(s, Nb) mix_columns(s, Nb) add_round_key(s, w, rnd, Nb) sub_bytes(s, AES_SBOX, Nb) shift_rows(s, Nb) add_round_key(s, w, Nr, Nb) return state_to_bytes(s, Nb) def rijndael160_decrypt_block(ct20, key28, Nb = 5, Nk = 7): Nr = rijndael_rounds(Nb, Nk) w = key_expansion(key28, Nb, Nk, AES_SBOX) s = bytes_to_state(ct20, Nb) add_round_key(s, w, Nr, Nb) for rnd in range(Nr - 1, 0, -1): inv_shift_rows(s, Nb) sub_bytes(s, INV_SBOX, Nb) add_round_key(s, w, rnd, Nb) inv_mix_columns(s, Nb) inv_shift_rows(s, Nb) sub_bytes(s, INV_SBOX, Nb) add_round_key(s, w, 0, Nb) return state_to_bytes(s, Nb) # ---------------- PKCS#7 padding for 20-byte blocks ---------------- BLOCK_SIZE = 20 def pkcs7_pad(data, block_size = BLOCK_SIZE): pad_len = block_size - (len(data) % block_size) if pad_len == 0: pad_len = block_size return data + bytes([pad_len]) * pad_len def pkcs7_unpad(padded, block_size = BLOCK_SIZE): if not padded or (len(padded) % block_size) != 0: raise ValueError("bad padded length") pad_len = padded[-1] if pad_len < 1 or pad_len > block_size: raise ValueError("bad padding") if padded[-pad_len:] != bytes([pad_len]) * pad_len: raise ValueError("bad padding bytes") return padded[:-pad_len] # ---------------- High-level encrypt/decrypt with PBKDF2 key + post-xor mask ---------------- def derive_key_and_mask(password_b, salt, ITER): enc_data_48 = hashlib.pbkdf2_hmac("sha256", password_b, salt, ITER, dklen=48) key28 = enc_data_48[:28] mask20 = enc_data_48[28:] return key28, mask20 def xor_bytes(a, b): return bytes(x ^ y for x, y in zip(a, b)) # ---------------- CBC 模式下的加密/解密逻辑 ---------------- def encrypt(data, password_b, salt, ITER): """ CBC 模式加密 (逻辑互换版): 1. PKCS7 Padding 2. 使用 mask20 作为初始 IV 3. 每个块:Plaintext XOR Previous_Ciphertext -> Decrypt_Core -> Ciphertext """ key28, iv = derive_key_and_mask(password_b, salt, ITER) # 自动进行 PKCS7 填充 data_p = pkcs7_pad(data, BLOCK_SIZE) out = bytearray() prev_block = iv # 初始 IV for i in range(0, len(data_p), BLOCK_SIZE): plaintext_block = data_p[i:i + BLOCK_SIZE] # CBC 核心:明文先与前一个密文块异或 mixed = xor_bytes(plaintext_block, prev_block) # 逻辑互换:调用原始的 decrypt 核心作为加密引擎 ciphertext_block = rijndael160_decrypt_block(mixed, key28, Nb=5, Nk=7) out += ciphertext_block prev_block = ciphertext_block # 更新 IV 为当前密文块 return bytes(out) def decrypt(ciphertext, password_b, salt, ITER): """ CBC 模式解密 (逻辑互换版): 1. 使用 mask20 作为初始 IV 2. 每个块:Ciphertext -> Encrypt_Core -> XOR Previous_Ciphertext -> Plaintext 3. 移除 Padding """ if len(ciphertext) % BLOCK_SIZE != 0: raise ValueError("密文长度必须是 20 字节的倍数") key28, iv = derive_key_and_mask(password_b, salt, ITER) out = bytearray() prev_block = iv # 初始 IV for i in range(0, len(ciphertext), BLOCK_SIZE): ciphertext_block = ciphertext[i:i + BLOCK_SIZE] # 逻辑互换:调用原始的 encrypt 核心作为解密引擎 decrypted_core = rijndael160_encrypt_block(ciphertext_block, key28, Nb=5, Nk=7) # CBC 核心:解密后的数据与前一个密文块异或得到明文 plaintext_block = xor_bytes(decrypted_core, prev_block) out += plaintext_block prev_block = ciphertext_block # 更新 IV 为当前密文块(注意是解密前的密文) # 移除 PKCS7 填充 return pkcs7_unpad(bytes(out), BLOCK_SIZE) def encode_varint(x: int) -> bytes: if x < 0: raise ValueError("varint encoder expects non-negative int") out = bytearray() while True: b = x & 0x7F x >>= 7 if x: out.append(b | 0x80) else: out.append(b) break return bytes(out) def encode_key(field_number: int, wire_type: int) -> bytes: return encode_varint((field_number << 3) | wire_type) def field_length_delimited(field_number: int, data) -> bytes: if isinstance(data, str): data = data.encode("utf-8") return encode_key(field_number, 2) + encode_varint(len(data)) + data MASK128 = (1 << 128) - 1 R = 0xE1000000000000000000000000000000 # GCM reduction constant (SP800-38D) # ---------------- AES-ECB 单块 ---------------- def aes_ecb_encrypt_block(key: bytes, block16: bytes) -> bytes: if len(block16) != 16: raise ValueError("block16 must be 16 bytes") if len(key) not in (16, 24, 32): raise ValueError("key must be 16/24/32 bytes") from Crypto.Cipher import AES # type: ignore return AES.new(key, AES.MODE_ECB).encrypt(block16) # ---------------- GCM: inc32 ---------------- def inc32(counter16: bytes) -> bytes: """只对最后32-bit(big-endian)+1,前12字节不变。""" if len(counter16) != 16: raise ValueError("counter16 must be 16 bytes") prefix = counter16[:12] c = int.from_bytes(counter16[12:], "big") c = (c + 1) & 0xFFFFFFFF return prefix + c.to_bytes(4, "big") # ---------------- GF(2^128) 乘法 (GCM) ---------------- def gf128_mul_gcm(x: int, y: int) -> int: """ 标准 GHASH 乘法:按 big-endian bit 顺序。 """ z = 0 v = x for i in range(128): if (y >> (127 - i)) & 1: z ^= v if v & 1: v = (v >> 1) ^ R else: v >>= 1 return z & MASK128 def ghash(H: bytes, aad: bytes, c: bytes) -> bytes: """ GHASH_H(A, C),最后包含 len(A)||len(C) (bit) 的长度块。 """ if len(H) != 16: raise ValueError("H must be 16 bytes") H_int = int.from_bytes(H, "big") X = 0 def iter_blocks(data: bytes): for i in range(0, len(data), 16): blk = data[i:i+16] if len(blk) < 16: blk = blk + b"\x00" * (16 - len(blk)) yield blk for blk in iter_blocks(aad): X = gf128_mul_gcm(X ^ int.from_bytes(blk, "big"), H_int) for blk in iter_blocks(c): X = gf128_mul_gcm(X ^ int.from_bytes(blk, "big"), H_int) len_block = (len(aad) * 8).to_bytes(8, "big") + (len(c) * 8).to_bytes(8, "big") X = gf128_mul_gcm(X ^ int.from_bytes(len_block, "big"), H_int) return X.to_bytes(16, "big") # ---------------- GCM: CTR 加密/解密 ---------------- def gcm_ctr_crypt(key: bytes, J0: bytes, data: bytes) -> bytes: """ GCM 的加解密(CTR):从 counter=inc32(J0) 开始。 """ if len(J0) != 16: raise ValueError("J0 must be 16 bytes") out = bytearray() counter = inc32(J0) for off in range(0, len(data), 16): block = data[off:off+16] ks = aes_ecb_encrypt_block(key, counter) out.extend(bytes(b ^ k for b, k in zip(block, ks[:len(block)]))) counter = inc32(counter) return bytes(out) # ---------------- GCM: 由 nonce(12) + plaintext 计算 ciphertext 和 tag ---------------- def gcm_encrypt_and_tag(key: bytes, nonce12: bytes, plaintext: bytes, aad: bytes = b""): """ 返回 (ciphertext, tag16) - J0 = nonce || 0x00000001 (nonce长度=12时标准写法) - H = AES_K(0^128) - C = CTR(J0, P) - S = AES_K(J0) - tag = S XOR GHASH(H, AAD, C) """ if len(nonce12) != 12: raise ValueError("nonce must be 12 bytes") J0 = nonce12 + b"\x00\x00\x00\x01" H = aes_ecb_encrypt_block(key, b"\x00" * 16) ciphertext = gcm_ctr_crypt(key, J0, plaintext) S = aes_ecb_encrypt_block(key, J0) g = ghash(H, aad, ciphertext) tag = bytes(x ^ y for x, y in zip(S, g)) return ciphertext, tag # ---------------- 拼包:4字节长度 + 12字节nonce + (ciphertext||tag) ---------------- def build_packet(nonce12: bytes, ciphertext: bytes, tag16: bytes) -> bytes: if len(nonce12) != 12: raise ValueError("nonce must be 12 bytes") if len(tag16) != 16: raise ValueError("tag must be 16 bytes") body = nonce12 + ciphertext + tag16 length = len(body) # = 12 + len(ciphertext) + 16 return length.to_bytes(4, "little") + body def make_packet_from_nonce_plain(key: bytes, nonce12: bytes, plaintext: bytes, aad: bytes = b""): ciphertext, tag16 = gcm_encrypt_and_tag(key, nonce12, plaintext, aad=aad) pkt = build_packet(nonce12, ciphertext, tag16) return pkt n = int.from_bytes(bytes.fromhex("BF6B322252C0B0BBD8FC0E11FC25DAFE0DEEBE1F65AF566C18864F087413FDB82056A7250C7AC2B7B1AEB101708974AC3B0AF3E9C23F63F7D061ADD18D0EB641E7007A5CDA3CAEE3D8DB7AD27810D587C35C6DE4C5CF617CB60AC968F63D57FACCAD5297C4E7E5C59237BDE3076CE08979182CA083B2658453C00E67108F1F12E5F9B9DB7421BEFA0C1356565F78F8216761BF1EE50A95A4604E44D2D45EF1932010CD91819D6530337A9737769187576A4A914DFFB7167444F146EEA9FED405CECE0FC49366DDBDDA0F8903328CB2B585A407BDFE05FD4FDA6325C2C85F0817BAAD5A0517CCDC4CFC051F6B5C37FB23692AA0D2C8E562991015FA333D571CEA5EA481D4862CECCA7C98523569793F50F55E24D6206DA9E688B3980EBF9F2B691B9F59A794355B438355D8D88CA358D7C8C83CD0B330C86BA0571B5F93C633B0E2B5C9E17C91971383C4AD5E3357028B558893CF8E8C06ADA01ABC0C0A2B1B2F6AF38F69616DE7371AB4AE15FAFEA7CDAEA5C4B13064DC6B89A3D5996A1613E973B0B9AD9FF8F572165E5A587AC6D3A6FA578D304FC45E181B77FCE97067122245126AD5046BEB93F6A17EC4BD5A801396FE440D955EDFD9248B3C0018752D574C4BB62A91117909CCDAE08E26CC47A9DD9623A9FE476AB7BA647EE15BB338CFC3174535D13D7B6C74AFA7B514AC511A6FEC04607C78CA2ECE7CD128AF1558B7"), "big") d = int.from_bytes(bytes.fromhex("32C892BD6E6CF6B66F83B78BE7F4771C0DC0382A8644B54DEA57BFA20381C63F523D0B0D16397F6D52B380FC5BC9EBED41A0CF434628A131FED3DB548BF2CA41C3B269C4369600E42C0556997E07214F6A721C29A49D3744E9DB04C25709C14CA57E9A39EFA082621F3FB09E09BB45FAD2E8A9F64FDA457A8CE9982899C90EBA69CF0E12FDC572304E81D6D7056F478D3D2B3E9448B9BD27A5F13DEB1D32AF2E944440F58888A46EDC497AD2D91F14E4092C0D4EBF37E8BA220C4D00469377D6AE9E16AAD55C6619D73F65DF364B03A28AF910A0C442FC8871ECF9F8AA4624147F8F3C21BBC5BAF0A5B00A3CE67367AA665D4BDB8036F3289E8EE6192FFDEB8A800A60196CF0C73E75EA5B397057451ADFD512AEE8F1E2C04F959B74270DD7A2F434A3C378C50734570CFFF1880C3D0879A4378AB2E06E2A51C4021205F8CC8D5A24878DB2457984F0BDA467D617CBC92AE65D154C1C4A98740007F4DB588DEBABD1579EA1E5162E3A7B716FB2ABC99274A8E252C2DF32AD674C85219B55213BF4C7621E5E7D583F04F69A68391F1F93FC472425900C9B51DECBDE9B2F742753AF51B9F1C146119D628D379A427D92C8F860D4D662DAFB0E9AF766B0FE580E3325E90F364F8C747095D4A259803139C3937B58EF20E601E8B78B70C2093A4D7335847EF18E6CFD8694BD6928350D66F3809B573C73A00889411C0B97C1204F6986"), "big") e = 65537 def gen_packet(license_code): password_b = b"mypassword" salt = bytes.fromhex("1020304050") ITER = 1000 TARGET_HEX = "".join(license_code.split("-")) calculated_sig = pow(int.from_bytes(bytes.fromhex(TARGET_HEX), "big"), e, n) raw_data = b'{"license_code":"'+license_code.encode()+b'","sign":"'+calculated_sig.to_bytes((calculated_sig.bit_length() + 7) // 8, 'big').hex().encode()+b'"}' ct = encrypt(raw_data, password_b, salt, ITER) protobuf_msg = b"".join([ field_length_delimited(1, ct), # enc_data = 20 bytes (派生) field_length_delimited(2, password_b), # password field_length_delimited(3, salt), # salt field_length_delimited(4, hashlib.sha256(raw_data).digest()), # sha256_hash ]) b64 = b64encode(protobuf_msg) buffer = zstd_compress(b64) key = bytes.fromhex("f6778d8728d8f17ce8c5c81f45c3d5fd869ca851b7575be540776f4f26c1140d") nonce12 = b"b" * 12 packet = make_packet_from_nonce_plain(key, nonce12, buffer, aad=b"") return packet if __name__ == "__main__": packet = gen_packet("612e2577-5b06-4bc3-b84b-b3c126b43662") print(int.from_bytes(packet[:4], byteorder="little")) print(packet[16:].hex()) ~~~ 远程连接的exp如下 ~~~python from pwn import * import subprocess from solve import * # 设置调试模式 context.log_level = 'debug' host = '223.6.249.127' port = 11240 io = remote(host, port) # 1. 精确提取挑战指令 log.info("正在解析挑战信息...") io.recvuntil(b"plz run this command to get solve result") cmd = "" while True: line = io.recvline().decode().strip() if "hashcash" in line: cmd = line break log.info(f"提取到的命令: {cmd}") # 2. 执行并发送结果 try: solution = subprocess.check_output(cmd, shell=True).strip() log.success(f"计算结果: {solution.decode()}") io.sendline(solution) except Exception as e: log.error(f"本地执行失败: {e}") io.close() exit() # 3. 等待关键提示并停顿 try: io.recvuntil(b"license code:") code = io.recvline().strip() log.success(f"收到 License Code: {code.decode()}") try: payload = gen_packet(code.decode()) log.info(f"正在发送长度为 {len(payload)} 的字节流...") io.send(payload) except ValueError: log.error("输入的不是有效的十六进制字符串!") # -------------------------- except EOFError: log.error("服务器在验证后断开连接。") io.interactive() ~~~ 拿到远程flag  最后修改:2026 年 02 月 03 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 如果觉得我的文章对你有用,请随意赞赏