关于反混淆:这四个棘手的C代码背后的概念

Concept behind these four lines of tricky C code

为什么此代码给出输出C++Sucks?它背后的概念是什么?

1
2
3
4
5
6
7
#include <stdio.h>

double m[] = {7709179928849219.0, 771};

int main() {
    m[1]--?m[0]*=2,main():printf((char*)m);    
}

在这里测试一下。


数字7709179928849219.0具有以下二进制表示形式,即64位double

1
2
01000011 00111011 01100011 01110101 01010011 00101011 00101011 01000011
+^^^^^^^ ^^^^---- -------- -------- -------- -------- -------- --------

+表示符号的位置;指数的^和尾数的-(即没有指数的值)。

由于表示使用二进制指数和尾数,因此将数字加倍将使指数增加1。你的程序精确地做了771次,所以从1075开始的指数(10000110011的十进制表示)最后变成1075+771=1846;1846的二进制表示是11100110110。结果模式如下:

1
2
3
01110011 01101011 01100011 01110101 01010011 00101011 00101011 01000011
-------- -------- -------- -------- -------- -------- -------- --------
0x73 's' 0x6B 'k' 0x63 'c' 0x75 'u' 0x53 'S' 0x2B '+' 0x2B '+' 0x43 'C'

此图案与您看到的打印字符串相对应,只是向后。同时,数组的第二个元素变为零,提供了空终止符,使得字符串适合传递给printf()


更具可读性的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
double m[2] = {7709179928849219.0, 771};
// m[0] = 7709179928849219.0;
// m[1] = 771;    

int main()
{
    if (m[1]-- != 0)
    {
        m[0] *= 2;
        main();
    }
    else
    {
        printf((char*) m);
    }
}

它递归地调用main()771次。

一开始是m[0] = 7709179928849219.0,代表C++Suc;C。每次通话中,m[0]都会翻倍,以"修复"最后两个字母。在上一次调用中,m[0]包含C++Sucks的ASCII字符表示,m[1]只包含零,因此它对C++Sucks字符串具有空终止符。所有这些都是在假定m[0]存储在8个字节上的情况下进行的,因此每个字符占用1个字节。

如果没有递归和非法的main()调用,它将如下所示:

1
2
3
4
5
6
double m[] = {7709179928849219.0, 0};
for (int i = 0; i < 771; i++)
{
    m[0] *= 2;
}
printf((char*) m);


免责声明:这个答案被张贴到问题的原始形式,它只提到C++,并包含了C++头。这个问题转化为纯C是由社区完成的,没有原始提问者的意见。

形式上讲,不可能对这个程序进行推理,因为它是不正确的(即它不是合法的C++)。它违反了C++ 11 [Basic,St.Madi] P3:

The function main shall not be used within a program.

除此之外,它还依赖于这样一个事实:在一台典型的消费计算机上,double的长度为8字节,并且使用某种众所周知的内部表示。对数组的初始值进行计算,以便在执行"算法"时,第一个double的最终值将是这样的:内部表示(8字节)将是8个字符C++Sucks的ASCII码。数组中的第二个元素是0.0,其第一个字节在内部表示中是0,使其成为有效的C样式字符串。然后使用printf()发送到输出。

在上面的一些内容没有保留的硬件上运行这个命令将导致垃圾文本(或者甚至可能导致访问越界)。


也许理解代码的最简单的方法是逆向处理。我们将用一个字符串打印出来——为了平衡,我们将使用"C++岩石"。关键点:和原版一样,它只有八个字符长。因为我们要像原稿一样(粗略地)打印出来,然后按相反的顺序打印出来,我们将从相反的顺序开始。对于第一步,我们将把该位模式看作一个double,并打印出结果:

1
2
3
4
5
6
7
8
#include <stdio.h>

char string[] ="skcoR++C";

int main(){
    printf("%f
"
, *(double*)string);
}

这就产生了3823728713643449.5。所以,我们想用一些不明显,但很容易逆转的方法来操纵它。我将半任意地选择乘256,这给了我们978874550692723072。现在,我们只需要编写一些模糊的代码,将其除以256,然后按相反的顺序打印出各个字节:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>

double x [] = { 978874550692723072, 8 };
char *y = (char *)x;

int main(int argc, char **argv){
    if (x[1]) {
        x[0] /= 2;  
        main(--x[1], (char **)++y);
    }
    putchar(*--y);
}

现在我们有了大量的强制转换,将参数传递给(递归的)main,这些参数完全被忽略了(但是计算增量和减量的计算是非常关键的),当然,为了掩盖我们所做的事情确实非常直接的事实,这个看起来完全任意的数字是非常重要的。

当然,因为这一点是模糊的,如果我们愿意,我们也可以采取更多的步骤。例如,我们可以利用短路评估,把我们的if语句转换成一个表达式,所以主体如下:

1
2
x[1] && (x[0] /= 2,  main(--x[1], (char **)++y));
putchar(*--y);

对于任何不习惯模糊代码(和/或代码高尔夫)的人来说,这看起来确实很奇怪——计算并丢弃一些毫无意义的浮点数的逻辑andmain的返回值,这些值甚至都没有返回值。更糟糕的是,如果没有意识到(并思考)短路评估是如何工作的,那么它甚至可能无法立即明显地避免无限递归。

我们的下一步可能是将打印每个字符与查找该字符分开。通过生成正确的字符作为main的返回值,并打印出main返回的内容,我们可以很容易地做到这一点:

1
2
x[1] && (x[0] /= 2,  putchar(main(--x[1], (char **)++y)));
return *--y;

至少在我看来,这已经足够模糊了,所以我就不谈了。


它只是构建了一个双数组(16字节),如果被解释为字符数组,则为字符串"c++吮吸"建立ASCII代码。

但是,代码不适用于每个系统,它依赖于以下一些未定义的事实:

  • double正好有8个字节
  • 迂回性

下面的代码打印了C++Suc;C,所以整个乘法过程只针对最后两个字母。

1
2
3
double m[] = {7709179928849219.0, 0};
printf("%s
"
, (char *)m);


其他人已经很彻底地解释了这个问题,我想补充一点,根据标准,这是未定义的行为。

C++ 11 3.61/3主函数

The function main shall not be used within a program. The linkage (3.5) of main is implementation-defined. A program that defines main as deleted or that declares main to be inline, static, or constexpr is ill-formed. The name main is not otherwise reserved. [ Example: member functions, classes, and enumerations can be called main, as can entities in other namespaces. —end example ]


代码可以这样重新编写:

1
2
3
4
5
6
7
8
9
10
void f()
{
    if (m[1]-- != 0)
    {
        m[0] *= 2;
        f();
    } else {
          printf((char*)m);
    }
}

它所做的是在EDCOX1×0数组EDCOX1(7)中产生一组字节,它对应于字符"C++吸吮",后面跟着一个空终止符。他们通过选择一个双精度值来模糊代码,在标准表示法中,当该值翻倍771次时,将产生一组字节,该字节带有数组第二个成员提供的空终止符。

请注意,此代码不能在不同的endian表示下工作。另外,不允许打电话给main()


首先,我们应该记住,双精度数字以二进制格式存储在内存中,如下所示:

(i)符号1位

(ii)指数为11位

(iii)52位

位的顺序从(i)降到(iii)。

首先将十进制分数转换为等价的分数二进制数,然后将其表示为二进制的数量级形式。

因此,编号7709179928849219.0变为

1
2
3
4
(11011011000110111010101010011001010110010101101000011)base 2


=1.1011011000110111010101010011001010110010101101000011 * 2^52

现在,同时考虑数量位1。忽略不计,因为所有数量级方法应以1开头。

所以量级部分变成:

1
1011011000110111010101010011001010110010101101000011

现在2的幂是52,我们需要把偏压数加成2^(指数-1的位)-1。即2^(11-1)-1=1023,所以我们的指数变成52+1023=1075。

现在我们的代码用2771倍的倍数使这个数字的指数增加了771倍。

所以我们的指数是(1075+771)=1846,它的二进制等价物是(11100110110)。

现在我们的数字是正数,所以我们的符号位是0。

所以我们修改的数字变成:

符号位+指数+大小(位的简单串联)

1
0111001101101011011000110111010101010011001010110010101101000011

由于m被转换成char指针,我们将把位模式从lsd中分为8块。

1
01110011 01101011 01100011 01110101 01010011 00101011 00101011 01000011

(十六进制等效值为:)

1
 0x73 0x6B 0x63 0x75 0x53 0x2B 0x2B 0x43

ASCII CHART从字符图中可以看出:

1
s   k   c   u      S      +   +   C

现在,一旦将其设置为m[1]为0,这意味着一个空字符

现在假设您在一个小的endian机器上运行这个程序(低位存储在低位地址中),那么指针m指向低位地址位,然后在8个chuck中取位(类型转换为char*),最后一个chunk中遇到00000000时printf()停止……

但此代码不可移植。