当我再次翻阅《Expert C Programming》这本书时,我偶然发现了国际模糊 C 代码竞赛的“轻松”部分。这是一场编写最晦涩难懂的代码的竞赛。C 语言有一个编写令人困惑的代码的竞赛,这可能说明了该语言的一些问题。我想看看这个竞赛的参赛作品是如何运作的。在互联网搜索中找不到任何解释,我决定自己调查一下。
IOCCC 的灵感来自 Steve Bournes 的决定,他决定在用 C 的语法编写 Unix shell 时(滥用)使用 C 预处理器,这种语法更像 Algol-68,带有明确的结束语句提示,代码如下
if
...
fi
他使用类似以下的代码实现了这一点
#define IF if(
#define THEN ){
#define ELSE } else {
#define FI ;}
这让他可以编写如下代码
IF *s2++ == 0
THEN return(0);
FI
正如专家 C谈到此类代码时所说;
避免使用任何修改底层语言的 C 预处理器
1987 年的早期获胜者之一是 Korn shell 的作者 David Korn 的一句俏皮话(这些 shell 作者是怎么回事):
main(){printf(&unix["\021%six\012\0"], (unix)["have"]+"fun"-0x60);}
就是这样。继续编译它。它会打印什么?
它无法在 Microsoft 上运行(提示!),但我发现它已在在线编译器ideone上,您可以尝试一下。(添加了一些内容以使其在那里运行,但其他方面相同。)
它只是打印
unix
这是从哪里来的?看起来像是一个名为的数组unix
,但它没有在这里声明。是unix
关键字吗?它是否以某种方式打印变量名称?
我盲目地尝试通过添加
printf(unix);
最后让它告诉我printf
需要一个char *
而不是一个int
。
将其打印出来int
告诉我它的值为 1。这让我认为它是一个#define
,就像将其定义为在 Unix 系统上编译一样。搜索gcc 源代码,我发现它是一个运行时目标规范。这解释了为什么它在 Windows 上不起作用。
unix
就是 1。重写后我们有
main(){printf(&1["\021%six\012\0"], (1)["have"]+"fun"-0x60);}
那么这unix
不是数组变量的名称,但是 1[] 如何工作?我以前见过这个,这是我最喜欢的 C 事实之一。
C 起源于 BCPL 语言。BCPL的创建者 Martin Richards 博士说道;
单值间接运算符 ! 以指针为参数并返回指向的字的内容。如果 v 是指针,!(v+I) 将访问 v+I 指向的字。… ! 的二元版本定义为 v!i = !(v+I)。v!i 的行为类似于下标表达式,其中 v 是一维数组,I 是整数下标。请注意,在 BCPL 中,v!5 = !(v+5) = !(5+v) = 5!v。在 C 中也是如此,v[5] = 5[v]。
换句话说,下标只是对指针进行加法运算,由于加法是可交换的,所以下标运算符也是可交换的。继续尝试一下。
int x[] = {1, 2, 3};
printf("%d\n%d\n", x[1], 1[x]);
那么是什么1["\021%six\012\0"]
?按照我们使用下标运算符访问数组元素的正常方式编写,我们有"\021%six\012\0"[1]
。仍然不典型,但你可以看到它是array[index]
,尽管通常不使用字符串文字。但那也行得通,也试试吧;
printf("%c\n", "hello, world"[1]);
我们在弄清楚这个问题的同时,先重写第一个数组。
main() {
char str[] = "\021%six\012\0";
printf(&str[1], (1)["have"]+"fun"-0x60);
}
这仍然有效。查看str
,我想知道哪个\0
是空字符(或 NUL 字符?)我以为 C 字符串文字默认有一个空字符。看看当我们删除它时会发生什么;
printf("%s", "\021%six\012");
印刷
█%six
我使用格式字符串,"%s"
因为我尝试打印的字符串包含格式字符%
。(C 编程提示:不要只打印字符串,以防printf(myStr)
万一您要打印的字符串包含格式字符。%s
按照上面所示进行打印。或者用作puts
下面提到的注释器。)
即使没有 ,它似乎仍然可以工作\0
。也许您必须在某些 ANSI C 之前的版本中将自己的空字符添加到字符串文字中?我猜不是,因为程序中的其他字符串没有它。或者那是更多的混淆?无论如何,让我们省略那个\0
。
既然我们已经开始了,让我们看看字符串的其余部分。 \xxx
是如何以八进制给出一个字符,\021
是一些控制字符和\012
换行符,或者\n
就像您通常在要打印的字符串末尾看到的那样。
知道\021
只是一个字符,str[1]
是%
。 &str[1]
那么就是从 开始的字符串%
。所以字符串实际上可以只是%six\n
,省略那个控制字符,我甚至不知道它是什么意思。
main() {
char str[] = "%six\n";
printf(str, (1)["have"]+"fun"-0x60);
}
传递给 的第一个字符串printf
是格式字符串,%s
表示将下一个字符串放在其位置。由于此字符串以 结尾ix
,我们可以猜测传递给 的下一个字符串printf
一定是un
某种字符串。这很简单,我们可以摆脱我们用来将其拉出的字符数组。
main() {
printf("%six\n", (1)["have"]+"fun"-0x60);
}
对于下一个字符串,我们有。中(1)["have"]+"fun"-0x60
有一个,现在我们必须找到它。unfun
我们再次使用 索引技巧(1)["have"]
。1 周围的括号是不需要的。同样,在旧 C 中是必需的,还是更多的误导? "have"[1]
给我们a
。字符的十六进制值为a
0x61,我们减去 0x60。然后这只是1+"fun"
。
与之前类似,"fun"
基本上解析为char *
。 向其添加 1 可得到从第二个字符开始的字符串,即un
。 这变成
main() {
printf("%six\n", "un");
}
这是未混淆的代码。
我喜欢一些更语义化的混淆,比如使用定义的单词,unix
也许会试图让你误以为它#define
本身就是以某种方式打印的。字符\021
被反转\012
可能是为了让你认为它很重要,但实际上它并没有被使用。格式字符串也可能%six
包含看起来像单词“six”的内容,也许是为了让你不认为“s”被用作格式字符。
从这么少的代码中可以解开很多东西,还有很多东西需要学习。