起因

source\repos 里有个 ConsoleApplication2,整个项目就一个 .cpp,40 行,没有头文件,没有第三方库。

它干的事:把字符串 upx!shell_1s_a_compression_she11 打乱一遍,打印;再拿原文重新打乱一遍(这次是反向的),打印。意思很明显——做个字符串混淆/还原的实验。

upx! 这个前缀不是随便选的。UPX 加壳后的节名叫 UPX0/UPX1,末尾还会留一段 UPX! 标记,分析工具靠它识别壳。这串东西看着像在模拟一个「壳留下的特征串」,然后琢磨怎么把它藏起来。

代码已整理到公开仓库:cnb.cool/t1mmoc/tspace/pwngore/net-reverse-snippets(string-shuffle/original.cpp 是原版,fixed.cpp 是改过一处的版本)。

我没打算跑它。我想知道它到底能不能还原。

一、代码长什么样

unsigned char Buffer[33];
unsigned char Dest[5] = {0};
unsigned char v6;
int i, j;

strcpy((char*)Buffer, "upx!shell_1s_a_compression_she11");
puts((char*)Buffer);

for (i = 14; i > 0; i -= 2) {
    for (j = 12; j > 0; j -= 4) {
        memcpy(Dest, &Buffer[j - 4], 1);
        memcpy(&Buffer[j - 4], &Buffer[j], 1);
        memcpy(&Buffer[j], Dest, 4);          // ← 这里
    }
    v6 = Buffer[i - 2];
    Buffer[i - 2] = Buffer[i - 1];
    Buffer[i - 1] = v6;
}
puts((const char*)Buffer);

后面是同一套逻辑的镜像版本(i 从 2 递增、j 从 4 递增),末尾那三个 memcpy 全写成 1 字节。注释里还留着两个版本的痕迹:

/*
_mbscpy(Dest, &Buffer[j - 4]);
_mbscpy(&Buffer[j - 4], &Buffer[j]);
_mbscpy(&Buffer[j], Dest);
*/

_mbscpy 是 strcpy 的多字节版本,按字符串(到 NUL 为止)复制。第三句 _mbscpy(&Buffer[j], Dest) 会把 Dest 整个字符串盖到 Buffer[j] 上,效果更糟。所以那三句被注释掉,换成了 memcpy。

问题是换的时候,第三句写成了 4。

二、跑一遍就知道:它还原不回来

函数本身没什么可推的,直接把两段循环照抄成 Python,喂原文进去就行。结果:

阶段输出
原文upx!shell_1s_a_compression_she11
第一段跑完ps!x
单独跑第二段(原文进,反向循环)hu!xp_leals1_s_compression_she11

第一段跑完只剩四个字符。看字节就明白了(索引 0 到 19):

70 73 21 78 00 00 00 00  00 00 00 00 00 00 00 00  6f 6d 70 72
 p  s  !  x  └────────── 12 个 0 ──────────┘        o  m  p  r

Dest[5] 声明时初始化成全 0,每次 memcpy(Dest, &Buffer[j-4], 1) 只填 Dest[0]。Dest[1]、Dest[2]、Dest[3] 从头到尾一直是 0。 所以 memcpy(&Buffer[j], Dest, 4) 的实际效果是:

把原来的一个字符写回去,外加往后面三个位置各写一个 0x00。

j 取到 8 和 4 时,两次 4 字节写立刻把 Buffer[4..11] 刷平;内层的三轮迭代又把 NUL 一路带到 12、13,外层 i 的相邻交换再把它们推到 14、15。最终 Buffer[4..15] 全是 0,puts 遇到第一个 0x00 就收工,只剩前 4 个字符。原文索引 15 的那个 c(compression 的首字母)也跟着一起没了,所以后面能看到的明文是从 ompression 开始的。

第二段跑出来的 hu!xp_leals1_s 看着像乱码,其实是原文前 14 个字符的一个排列(upx!shell_1s_a 这 14 个字符一个不少,只是位置换了)。也就是说第二段是「第一段的逆运算」,但它逆的是一个只写了 1 字节的版本——两边不对称,所以合不上。

三、把 4 改成 1,立刻互逆

只改这一处,其他全不动:

memcpy(&Buffer[j], Dest, 1);

再跑:

阶段输出
原文upx!shell_1s_a_compression_she11
第一段ps!xaule__s1hl_compression_she11
第二段(接在第一段后面)upx!shell_1s_a_compression_she11

完全还原。 一次不多一次不少。

这就把结论钉死了:这段代码的意图是对的(两段互为逆置换),错的只是第三个 memcpy 的长度参数。memcpy 的第三个参数是字节数,不是「元素个数」,也不是「缓冲区大小」——这是 C 里最常见的一类静默错误:它不报错,不崩,只是悄悄多写了三个字节。

四、为什么逆运算必须反着走

交换操作按定义是自逆的(swap(a,b) 再做一次就回去了),所以直觉上「正着跑一遍再正着跑一遍」就该还原。但它不是。

看 j 这一层到底交换了哪些位置。j 从 12 开始每轮减 4:8、4,配的是 (j-4, j):

(8,12) → (4,8) → (0,4)

这三对共用下标:8 出现在第一对和第二对里,4 出现在第二对和第三对里。它不是一个「互不相干的对换集合」,而是一条链:12 的字符被搬到 8,8 的搬到 4,4 的搬到 0。

链上的操作不可交换。所以在第二段里,j 必须从 4 开始往 12 走:

(0,4) → (4,8) → (8,12)

这才是把链一节一节拆回去的顺序。

再看 i 那一层,(i-2, i-1) 取到的是 (12,13)、(10,11)……(0,1),两两不重叠。这一层谁先谁后都一样,所以 i 的方向在数学上无所谓——但代码里两段还是反着写了,算是对称性的一种尊重。

可迁移的结论:判断一段置换能不能简单反向还原,只看一件事——这些交换共享不共享下标。不共享,直接重跑;共享,必须严格逆序。

五、只动了 14 个字节

i 最大取 14,i-1 最大 13;j 最大 12。所以索引 0..13 之外,一个字符都没碰(前面那串 0 蔓延到 15 完全是 4 字节越界写的副作用,不是设计)。

原串 32 个字符,后 18 个 _a_compression_she11 全程原地不动。输出里那段明文就摆在那里——任何按 _compression、shell、she11 做字符串搜索的工具都能一把命中。

这就是这类手工混淆的真实强度:能挡住「strings 一把梭」的最懒的观察,挡不住任何一个会看交叉引用的人。而且它有一个更致命的特征——循环边界是硬编码的常量(14 和 12),字符串长度一改,混淆范围就错位,等于自己在代码里标了个「这里在处理一个 32 字节的字符串」。

真要藏字符串,方向不是「换个更绕的置换」,而是:

做法强度代价
位置置换(本文这种)最低,可手工逆推无
异或固定 key低,key 就在代码里无
逐字节 + 长度异或(^ key ^ i)中,去掉明文统计特征无
运行期算出来(不进 .rdata)中,静态搜不到要写生成逻辑
对称加密 + 密钥派生高依赖库、体积

判断标准很简单:密钥在不在可执行文件里。在,就是混淆;不在,才叫加密。

六、顺带一句 ConsoleApplication1

同一个目录下还有个 ConsoleApplication1,六行:

void main() {
    std::cout << std::hex << LOCALE_SISO3166CTRYNAME2 << std::endl;
    return;
}

打一个 Windows 常量出来,纯实验,没有下文。LOCALE_SISO3166CTRYNAME2 是「区域设置 → 国家/地区代码」那个查询用的常量,"zh-CN" 那一类值就是它查出来的。放在这里应该是查某份文档时的产物。

收尾

这段代码我从头到尾没编译,只把它抄成脚本跑了两遍。收获比编译一遍大:

  1. memcpy 的第三个参数是字节数,多写的部分不会报错,只会悄悄改掉别人的字节。
  2. 一段置换能不能「重跑即还原」,取决于交换的下标是否共享。共享就是链,链必须逆序拆。
  3. 循环边界写成常量,等于把被处理数据的长度写在了代码里。
  4. 混淆和加密的分界线是密钥在哪,不是算法看起来有多绕。

以及一个不算技术的感受:这段代码的注释里躺着正确答案。那三句 _mbscpy 被注释掉的位置,正好就是最后出错的位置——当时改成 memcpy 的时候,大概只想着「换个不吃 NUL 的复制」,没去想第三个参数该填几。