文章总结: 本文分析了PolarisCTF逆向题目ShakeLife的解题思路,该题通过重定位表注入技术在程序装载阶段修改fini数组和代码段,隐藏真实校验逻辑。解题需动态调试获取内存中的真实代码,发现被篡改的密文后使用HPC算法解密获得flag。文档揭示了防止AI解题的三种策略,并提供了完整的解密代码实现。 综合评分: 85 文章分类: 逆向分析,CTF,二进制安全,漏洞分析,红队
PolarisCTF Reverse-ShakeLife WriteUp
原创
TokameinE TokameinE
星盟安全团队
2026年4月3日 14:20 陕西
前言
题目设计来自于一个古老的 trick,通过劫持和注入重定位表项中的项目,使得程序在装载阶段能够向内存中注入 shellcode 以实现几乎无感的代码注入能力。
这个 trick 最早在国外的 CTF 上出现,并在五年前的 aliyunctf 上得到了改进,这次的 ShakeLife 也参考了 aliyunctf 的思路并做了一些简化,当年的题目可以参考这里:
| https://gist.github.com/Riatre/a6592f84fd49f5ac318fdf0fb098054b
相比于阿里云的题目,做了很多简化以让难度符合招新赛的标准,最初定级的时候我还犹豫过是不是定到中等难度比较好,因为找到关键信息以后剩下的工作让 AI 做也完全绰绰有余了。
出题缘由也很简单,由于最近 AI 的突飞猛进,我认为单纯的代码分析可能对 AI来说完全无法构成任何难度,甚至一些需要动态调试的题目也可以在接入 MCP 以后让 agent 自动调试来完成解题,为此我认为要防止选手通过 AI 一把梭,主要有这么几种思路:
- 堆积代码量,挤爆它的上下文,使得无法正常分析
- 用明显但错误的逻辑诱导 AI 进行错误的分析
- 让 AI 无法直接阅读到关键信息
我认为第二和第三种方法能够很好的防止题目被 AI 一眼顶针,反倒是善用搜索引擎更容易解出本题,属实有些文艺复兴了。
这里先提前放一张图证明真的有 flag:
正文
说回正题,相信很多选手都试过用 IDA 一把梭,一打开程序,完整的代码校验流程就出来了:
__int64 __fastcall main(int a1, char **a2, char **a3){ ...... v26 = __readfsqword(0x28u); alarm(0x1Eu); std::allocator<char>::allocator(v22, a2); std::string::basic_string(v23, "Please enter the flag: ", v22); v3 = std::string::data(v23); std::operator<<<std::char_traits<char>>(&std::cout, v3); std::string::~string(v23); std::allocator<char>::~allocator(v22); std::istream::getline(&std::cin, s, 255LL); qmemcpy(v25, "3ff653606890a0591e204093a0d59202", 32); v4 = sub_2CF2(v25); v5 = sub_2CE0(v25); v15 = sub_2D08(v5, v4); if ( v15 != 1 ) { return 1; } else { v18 = 256LL; if ( sub_3BBA(v23, v25, 256LL) ) { n = strlen(s); if ( n == 43 ) { for ( i = 0LL; i < n; ++i ) { if ( s[i] <= 0x1Fu || s[i] > 0x7Eu ) { v7 = std::operator<<<std::char_traits<char>>(&std::cout, "Invalid character detected!"); std::ostream::operator<<(v7, &std::endl<char,std::char_traits<char>>); return 0; } } v16 = sub_2D34(s, &s[n], 0LL); if ( v16 >= 0 ) { sub_25D2(); sub_2593(); sub_26C1(); v20 = 8 * n; v24 = 0x807060504030201LL; v8 = sub_2D6A(&v24); v9 = sub_2D7C(&v24); v10 = sub_2D6A(&v24); sub_2D92(v10, v9, v8); v21 = 64LL; if ( sub_41D9(v23, s, byte_C4A0, 8 * n, &v24, 64LL) ) { if ( sub_2DE4(byte_C4A0[0]) >= 0.0 ) { sub_2E04(&v14); sub_2E44(v22, byte_C4A0, &byte_C4A0[n], &v14); sub_2E24(&v14); if ( sub_2F2A(v22) && n ) { v6 = 1; } else { if ( !memcmp(byte_C4A0, &unk_80E0, n) ) std::operator<<<std::char_traits<char>>(&std::cout, aCorrect); v6 = 0; } sub_2EE2(v22); } ..... return v6;}
一道经典的校验明文输入类型的题目,并且在比赛期间也已经有很多选手正确还原出了算法,但是却会发现,如果用 unk_80E0 处的数据进行解密,根本无法解出正确的明文!而且因为题目还提前过滤了输入必须是可见字符,因此不会有真的能够让程序直接输出 Correct 的输入,但是,真的如此吗?
不知道选手在做题过程中是否留意到了 IDA 分析出的函数列表里有这样两个没有被正常标记为外部函数的两个函数:
这两个函数的函数名确实来自于 std 标准库,但是我们可以发现它们的实现却根本不是库函数的标准实现,而且这种函数如果真的是外部引入,那就应该像上面的这些函数一样跳转到 plt 才对,所以这里的函数名其实是故意用来误导 IDA 和 AI 的,因为它们会把这两个函数当作标准库函数,而不会关心它们有没有问题。况且,如果尝试交叉引用跟踪,我们会发现这两个函数根本没有被使用到,一个标准库的函数,如果没被使用,理论上不会特地被编译进函数表里。
那么既然这两个函数并不是真正的标准库,它们的用途是什么呢?观察到用于对符号进行重定向所需的符号表:
这里可以很明显的看到,数据是异常的,IDA 也发现了这个问题,只不过它不会特地去提示你这里有问题。
而作为重定位的符号表,那么对应重定位项中的哪一项呢?
如果我们观察这份重定位表,就会发现用于重定向这两个符号时所用的偏移很奇怪,它根本不是 got 表,而且这两个明明是函数符号,却跟其他外部函数不一样,用的是 R_X86_64_COPY 类型而非 R_X86_64_GLOB_DAT
当然,除此之外还有一些额外的注入项目是 IDA 没办法直接识别到的:
但是如果我们仔细观察它们所用到的地址,也会发现这些内容本不该出现在这里。
相信到这一步,对于了解过 PWN 的选手应该会意识到什么。由于主要的流程发生在重定向阶段,也就是说,通过动态调试将断点下到程序的 start 函数以后,就能在内存里找到完整的执行程序了。
此时,不管是通过动态调试,还是直接把整块内存 dump 出来,我们都能够得到一个真正的,实际被运行的程序。而此时如果尝试去 diff 两个程序的不同之处,就能很容易发现有不少地方代码被篡改了。
发现了吗,第一个 fini 函数对应的偏移有些怪异,它实际上指向了 0x7345,但是你在 IDA 里是找不到这段代码的,因为它位于代码段冗余加载的部分。
最终我们会找到真正用于比较的地方,从这里拿到真正的密文,并通过之前还原出的解密函数解密即可。
#include <iostream>#include <vector>#include <string>#include <cstring>#include <iomanip>#include <cstdlib>#include <ctime>#include "hpc/hpc.h"
void print_hex(const std::string& label, const uint8_t* data, size_t len) { std::cout << label << ": "; std::cout << std::hex << std::setfill('0'); for (size_t i = 0; i < len; i++) { std::cout << std::setw(2) << static_cast<int>(data[i]); } std::cout << std::dec << std::endl; // Reset to decimal}
int main() { std::cout << "HPC Decryptor" << std::endl; std::cout << "=============" << std::endl;
// 1. Initialize Key uint8_t key[32]; std::string key_str = "3ff653606890a0591e204093a0d59202"; std::memcpy(key, key_str.data(), 32);
size_t key_bit_size = 32 * 8;
std::cout << "Key size: " << key_bit_size << " bits" << std::endl; print_hex("Key", key, 32);
struct HpcState state; if (!hpc_init(&state, key, key_bit_size)) { std::cerr << "Failed to initialize HPC state" << std::endl; return 1; } std::cout << "HPC State initialized successfully." << std::endl; // Ciphertext: 4d95d2d6a07921024ea81cb440d8c3f669c606176957d497ce7f4eab279638d7e35bf397bd5ee301f06e15 const uint8_t ciphertext_raw[] = { 0x4d, 0x95, 0xd2, 0xd6, 0xa0, 0x79, 0x21, 0x02, 0x4e, 0xa8, 0x1c, 0xb4, 0x40, 0xd8, 0xc3, 0xf6, 0x69, 0xc6, 0x06, 0x17, 0x69, 0x57, 0xd4, 0x97, 0xce, 0x7f, 0x4e, 0xab, 0x27, 0x96, 0x38, 0xd7, 0xe3, 0x5b, 0xf3, 0x97, 0xbd, 0x5e, 0xe3, 0x01, 0xf0, 0x6e, 0x15 }; size_t message_len = sizeof(ciphertext_raw); size_t data_bit_size = message_len * 8;
std::vector<uint8_t> ciphertext(message_len); std::vector<uint8_t> decrypted(message_len);
std::memcpy(ciphertext.data(), ciphertext_raw, message_len);
print_hex("Ciphertext (Hex)", ciphertext.data(), message_len);
// 4. Decrypt uint8_t tweak[8] = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; size_t tweak_bit_size = 64;
if (!hpc_decrypt(&state, ciphertext.data(), decrypted.data(), data_bit_size, tweak, tweak_bit_size)) { std::cerr << "Decryption failed" << std::endl; return 1; } print_hex("Decrypted (Hex)", decrypted.data(), message_len);
// 5. Output
std::string decrypted_str(reinterpret_cast<char*>(decrypted.data()), message_len); std::cout << "Decrypted Message: " << decrypted_str << std::endl;
return 0;}
后话
前面的内容就已经足够完成本题的解题了,接下来主要是聊点关于实现的问题。尽管前文我们说过,通过动态调试或运行时dump的方式已经能够完成本题,但是我相信有些选手可能会好奇,fini array 是如何被修改的,毕竟正常来说,开启了 FULL RELRO 的程序,运行时应该是无法修改的才对。
当然这也很好解答,正如所有 got 表项都在装载期间被写入,然后在写入后重新被改为只读一样,只需要在装载期间把 fini array 的数据改掉就行了。
但是如果直接在重定位表里添加对应的项目,智能的 IDA 会直接解析项目,然后把会被修改的地方提前改好。为了避免题目过于容易,因此我参考了之前 aliyunctf 用到的办法,说实话,真的很炫酷:通过修改重定位表项的数量,使得 ELF RELA Relocation Table 和 ELF JMPREL Relocation Table 相互重叠,造成对 ELF JMPREL Relocation Table 的二次解析。并在解析的过程中,修改 ELF JMPREL Relocation Table 的内容,这会让整个重定向表看起来正常不少。
具体来说:
-
R_X86_64_RELATIVE–*(uint64_t*)(elf_base+addr) = (elf_base+addend); -
R_X86_64_COPY– 记指定 symbol 解析出来的地址为 symbol_value,则实际效果为memcpy(elf_base+addr, symbol_value, symbol_size);
记得前面我们提到的那几份注入用的项目,其中新注入的几个 R_X86_64_RELATIVE 是用来修改 ELF Symbol Table 中 _ZNSt8ios_baseC2Ev 符号的 st_value 值,将其改为 0x1D18,不过 IDA 已经帮我们分析出来,并发现地址无法访问而标红了。
当程序解析到 R_X86_64_COPY _ZNSt8ios_baseC2Ev 时,会做类似 memcpy(base+0x0C7Ch,base+0x1D18,0xe) 的操作,目的是为了将 _ZNSt8ios_baseD2Ev 的元数据,这样,在接下来执行 R_X86_64_COPY _ZNSt8ios_baseD2Ev 的时候,就能将我们的目标数据写入到意想不到的地方去了。
具体来说,我们将 _ZNSt8ios_baseD2Ev 的符号值改为了 0x1a00 ,并修改了它的类型为 LOCAL,这样在执行 R_X86_64_COPY _ZNSt8ios_baseD2Ev 时,我们能够得到类似 memcpy(base+0x13D0,base+0x1a00,0x300),这里的 0x13d0 就对应了 ELF JMPREL Relocation Table,相当于我们直接将下面的跳转重定向表的内容改掉,转而注入了新的项目。而注入内容来自于 0x1a00 处:
R_X86_64_SIZE64– 指定 symbol index = 0,则该重定位项的实际效果为写*(uint64_t*)(elf_base+addr) = (addend+0);R_X86_64_RELATIVE–*(uint64_t*)(elf_base+addr) = (elf_base+addend);R_X86_64_64–*(uint64_t*)(elf_base+addr) = (symbol_value + addend);
其实就相当于一个任意地址读写,这里就对应我们向内存写入 shellcode 的实际项目了,当然除此之位还包含了原本的表项,否则正常数据不能解析会导致程序执行过程中 crash。
最后我们注入了这样的 shellcode:
/* The following constants will be overriden during build. */.equ ARRAY_OFFSET, 0.equ CORRECT_OFFSET, 0.equ MAIN_OFFSET_FROM_SHELLCODE, 0.equ MAIN_SIZE, 0
_begin: endbr64 /* Save registers if needed, but we are in a shellcode that exits or returns */
/* Calculate shellcode base address and main address */ call get_ripget_rip: pop r8 /* r8 = address of get_rip */ sub r8, get_rip - _begin /* r8 = address of _begin (shellcode base) */
mov r9, r8 /* r9 = shellcode base */ add r8, MAIN_OFFSET_FROM_SHELLCODE /* r8 = main address */
/* Calculate Magic Byte */ /* r8 = main_addr */ mov rcx, MAIN_SIZE xor r10, r10 /* r10 = accumulator for magic byte */ xor r11, r11 /* r11 = temp for byte load */
calc_magic_loop: mov r11b, byte ptr [r8] xor r10b, r11b inc r8 loop calc_magic_loop
/* r10b is the magic byte. Broadcast to 64-bit register r10 */ movzx r10, r10b mov rax, 0x0101010101010101 imul r10, rax /* r10 now has the magic byte repeated 8 times */
/* Load input array address */ /* ARRAY_OFFSET is relative to RIP at the lea instruction location? No, in previous script it was calculated as: offset = target_addr - prepare_sc_buf.address - 5 - 6 This implies it was relative to the instruction end?
The previous code used: lea rsi, [rip + ARRAY_OFFSET]
If we want to be consistent, we should keep using RIP-relative LEA for ARRAY_OFFSET if the Python script calculates it correctly.
However, since we now have the shellcode base in r9, we can use it! ARRAY_OFFSET passed from Python is (target_addr - shellcode_addr). So: */ lea rsi, [r9 + ARRAY_OFFSET]
/* Load target data address */ /* target_data is at the end of shellcode. We can use RIP relative or base relative. Let's use base relative since we have r9. */ lea rdi, [r9 + target_data - _begin]
/* Loop counter */ mov ecx, 8 /* 43 bytes / 8 = 5.375 -> 6 qwords? The target_data in previous tiara.s had 8 qwords. The new ciphertext is 43 bytes. 43 bytes needs 6 qwords (48 bytes). The last qword will be partially filled or 0-padded. Let's assume 6 qwords. */
compare_loop: /* Load value from input array */ mov rax, [rsi]
/* XOR with magic */ xor rax, r10
/* Load value from target data (already XORed with magic) */ mov rbx, [rdi]
/* Compare */ cmp rax, rbx jne fail
/* Next element */ add rsi, 8 add rdi, 8
/* Decrement counter */ dec ecx jnz compare_loop
success: mov eax, 1 /* sys_write */ mov edi, 1 /* stdout */ /* CORRECT_OFFSET is relative to shellcode base? Previous script: offset2 = correct_addr - prepare_sc_buf.address - 0x40 It seems it was trying to be RIP relative but with a weird offset. Let's assume CORRECT_OFFSET is (correct_addr - shellcode_addr). */ lea rsi, [r9 + CORRECT_OFFSET] mov edx, 10 /* length */ syscallfail: ret
/* Data section will be appended by Python script */target_data:
最终是将用户的输入走正常流程加密后的数据,跟注入到内存里的密文进行比较,一致的情况下用 syscall 直接输出标识。当然,为了增加一点点难度,用户输入还需要跟 main 函数的代码计算的 magic byte 做一次异或,因为我们在main函数里加了一点点反调试,所以如果有选手通过 patch 进行了这个操作,计算的 magic byte 会不一样,当然,才一字节,爆破一下也不是不行。
现在看来,整体的难度应该是比较低的,相比当时 aliyunctf 的原题,删减了很多关于隐藏执行流的内容,只保留了通过重定向表注入代码这项 trick,然后再加入一点点诱导 AI 行为的小巧思,主要还是希望选手们能够多动动手,实际去跑跑看,而不是直接让 AI 包办所有内容。
| 应该有不少能力不够强的 AI 在读到主流程里的比较函数之后走不动道,写来写去就盯着那个流程去跑了吧?
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:星盟安全团队 TokameinE TokameinE《PolarisCTF Reverse-ShakeLife WriteUp》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论