起因
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" 那一类值就是它查出来的。放在这里应该是查某份文档时的产物。
收尾
这段代码我从头到尾没编译,只把它抄成脚本跑了两遍。收获比编译一遍大:
memcpy的第三个参数是字节数,多写的部分不会报错,只会悄悄改掉别人的字节。- 一段置换能不能「重跑即还原」,取决于交换的下标是否共享。共享就是链,链必须逆序拆。
- 循环边界写成常量,等于把被处理数据的长度写在了代码里。
- 混淆和加密的分界线是密钥在哪,不是算法看起来有多绕。
以及一个不算技术的感受:这段代码的注释里躺着正确答案。那三句 _mbscpy 被注释掉的位置,正好就是最后出错的位置——当时改成 memcpy 的时候,大概只想着「换个不吃 NUL 的复制」,没去想第三个参数该填几。