ITPub博客

首页 > 数据库 > 数据库开发技术 > 顶级极客技术挑战赛,你敢来挑战吗?| 大神登峰造极

顶级极客技术挑战赛,你敢来挑战吗?| 大神登峰造极

原创 数据库开发技术 作者:极客重生 时间:2021-05-30 19:50:51 0 删除 编辑

大家好,我是极客君,去年鹅厂内部极客圈举办了第二次极客大赛,题目如下:


"实现一个世界上最小的程序来输出自身的MD5"


作为极客圈一员的我也参加了比赛,比赛竞争很激烈,为了争夺一个字节的优势,大家都拿出自己的绝活,比赛重新刷新了我对汇编的认知和对程序运行原理理解,昨天分享一个典型的朴素解法:


最小MD5挑战赛,你敢来战吗?


但这篇"菜鸟"朴素解法,已经让有些人看得不知所云,其实要完全理解其中一些技巧,可能需要实际操作一下,计算机实践很重要,看不懂也可以做个吃瓜群众,长长见识也可以。


上篇文章说过点赞在看超过20,今天就放出大神解法,我相信此法一出,评论区会一片惊呼:


A:天啊,这个男人不得了,他做到了最短!

B:神仙打架

C :  膜拜第一名大佬,各种技巧溜得飞起,学习了

     

D :  跪着看完了,厉害!

E  :  降维打击

...

文章稍微有点长,不想慢慢看完,可以直接拉到底,接下来该你上场表演了。

开阔眼界的大神解法

299字节打印自身的MD5,NO.1(冠军)

用 nasm 写汇编,手动构造 ELF 格式文件。ELF 文件分为三部分:

[A] [B] [C]

其中 A 的长度为 64 的倍数,B+C 的长度 <= 55。

A+B 包括:ELF 文件头、计算 MD5 一个 block 的代码、输出 16 进制的代码。尽可能压缩这部分代码的大小。

C 是对 A 计算 MD5(不加 padding)的中间结果,13 字节。(为什么是 13 不是 16?因为撞了 3 个字节,详见下文。)

思路

大的方向无非就这些:

  1. 撞:理想情况下,我们的程序就简单执行一条输出指令,撞出一个满足 MD5(print(md5_string)) == md5_string 的 md5_string。

  2. 利用外部能力:调用外部程序,或者利用 Linux 内核中的 MD5 实现。

  3. 算:程序读取自身,计算 MD5 并输出。

如果考虑撞,MD5 有 128 位,2 的 128 次方据说已经超过了宇宙中基本粒子的数量,穷举显然是不行的,只能用密码学的方法构造。鉴于本文作者的密码学知识接近于 0,就不卖弄了,不过据群里的同学说这样构造出来的文件会很大,并不适合本次比赛。

而利用外部能力的路已经被堵死,不允许 fork,也不允许使用 socket,此路不通。

看来可行的方法就是算,成了一个纯工程优化问题。这一块正好本文作者有点经验,研究生方向是编译器,做的题目是 code size reduction。

MD5 算法

第一想法是去抄开源代码,我一开始抄了 Linux kernel 里的 MD5 实现,但后来发现并不适合本次比赛。开源的 MD5 实现为了性能大都做了人工循环展开和常量预计算,会把代码撑得很大。不如直接照着 MD5 算法的伪码来写,维基百科上面的伪代码写得够清晰,就它了。

简单描述:

需要先对原始数据做 padding,将它的长度变成 64 字节的整数倍,MD5 算法在 64 字节长的 block 上进行。

具体 padding 方法:在原始数据后增加一字节 0x80,然后再增加若干个 0,直到总长度为 64 的倍数,并且最后至少留出 8 个字节的位置,用于填写 little endian 编码的原始数据总长度(按位计,即字节数乘以 8)。

Padding 完成后,对每个 64 字节 block 进行计算,block 的计算方法参考下一节的代码。每一轮计算结束后更新 16 字节的 MD5 状态,最后一轮计算完成后,将状态用 16 进制打印出来。

C++ 实现

以下是一个 C++ 写的完整实现,在不太损失可读性的前提下用较简短的写法,不考虑性能:

#include <cmath>
#include <cstdio>
#include <cstring>
#include <memory>

using namespace std;

static const uint8_t SHIFT[] = {7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21};

// 注:为了省事,下面代码假设了 CPU 是 little endian
static void compute_md5(void* result, const void* orig_data, size_t orig_size) {

uint32_t* hash = reinterpret_cast<uint32_t*>(result);
hash[0] = 0x67452301;
hash[1] = 0xefcdab89;
hash[2] = 0x98badcfe;
hash[3] = 0x10325476;

// 计算padding后的总长度
size_t padded_size;
if (orig_size % 64 > 55) {
padded_size = orig_size / 64 * 64 + 128;
} else {
padded_size = orig_size / 64 * 64 + 64;
}

// 增加padding
std::unique_ptr<char[]> padded_data(new char[padded_size]);
memset(padded_data.get(), 0, padded_size);
memcpy(padded_data.get(), orig_data, orig_size);
padded_data[orig_size] = 0x80;
*(uint64_t*)(padded_data.get() + padded_size - 8) = orig_size * 8;

// 开始计算
for (const uint32_t* data = (const uint32_t*)padded_data.get();
padded_size >= 64; data += 16, padded_size -= 64) {
uint32_t a = hash[0];
uint32_t b = hash[1];
uint32_t c = hash[2];
uint32_t d = hash[3];
for (int i = 0; i < 64; ++i) {
uint32_t f, g;
if (i < 16) {
f = ((c ^ d) & b) ^ d; // 等价于 (b & c) | ((~b) & d);
g = i;
} else if (i < 32) {
f = ((b ^ c) & d) ^ c; // (d & b) | ((~d) & c);
g = 5*i + 1;
} else if (i < 48) {
f = b ^ c ^ d;
g = 3*i + 5;
} else {
f = c ^ (b | (~d));
g = 7*i;
}
uint32_t K = static_cast<uint32_t>(fabs(sin(i + 1)) * 4294967296.0);
f += a + K + data[g % 16];
int shift_val = SHIFT[(i / 16) * 4 + (i % 4)];
f = (f << shift_val) | (f >> (32 - shift_val));
a = d;
d = c;
c = b;
b += f;
}
hash[0] += a;
hash[1] += b;
hash[2] += c;
hash[3] += d;
}
}

int main(int argc, char** argv) {
FILE* fp = fopen(argv[0], "rb");
if (fp == nullptr) {
perror("Failed to open file");
return 1;
}

char buffer[65536];
size_t len = fread(buffer, 1, sizeof(buffer), fp);
fclose(fp);

unsigned char result[16];
compute_md5(result, buffer, len);

for (int i = 0; i < 16; ++i) {
printf("%02x", result[i]);
}
putchar('\n');
return 0;
}

这个版本用 g++ -Os 编译出来,strip 一下,不做其他处理,大约 14 KB。

用汇编改写

接下来的任务就是想办法用汇编重写这个 14 KB 的程序,将代码压缩到最小。

手动构造 ELF

手动构造 ELF 网上能找到不少例子,例如:Smallest executable program (x86-64)。它使用 nasm 构造了一个最小的 64 位 ELF,先照着它搭个架子。

这个例子提供了两个思路:(1) ELF 头部有很多不重要的字段,可以用来放代码或数据 (2) ehdr 的最后 8 个字节和 phdr 的最前面 8 个字节可以重合。

借助 nasm 可以很容易地将 file size 硬编码到代码里。还有 %define、%if 等也可以帮助我们省很多事。

手动构造 ELF 以后,也不再需要再去读文件,加载到内存里的地址完全受我们控制了,直接去读。

系统调用

没有 C 库以后,系统调用需要自己写。x86-64 有 syscall 指令,发起系统调用很方便。

  • 入参:

    • eax = 系统调用号,可以在 /usr/include/asm/unistd_64.h 文件中找到

    • rdi, rsi, rdx, r10, r8, r9 分别为第 1 至 6 个参数

  • 出参:

    • rax = 返回值(如果失败,返回 -errno)

    • rcx, r11 被破坏(它们分别被 syscall 指令用来保存返回地址和 rflags)

    • 其他寄存器的值保留

假设栈顶 32 字节是计算好的 MD5 字符串,输出的系统调用:

   0:   b8 01 00 00 00          mov    eax,0x1        ; write 系统调用
5: bf 01 00 00 00 mov edi,0x1 ; 标准输出
a: 48 89 e6 mov rsi,rsp ; 指针
d: ba 20 00 00 00 mov edx,0x20 ; 长度 32 字节
12: 0f 05 syscall

汇编代码优化

  1. 预先计算 MD5 中间状态

    将 ELF 文件分为三部分:[A] [B] [C]

    其中 A 的长度为 64 的倍数,B+C 的长度 <= 55。

    A+B 包括:ELF 文件头、计算 MD5 一个 block 的代码、输出 16 进制的代码。

    C 是对 A 计算 MD5(不加 padding)的中间结果,16 字节,由辅助脚本计算并填入。(后面会把 C 变成 13 字节,见下文。)

    这样可以省掉外层循环,汇编只用计算最后 64 位字节。

  2. 避免 copy

    默认 text 段是不可写的,可以修改 p_flags,从 5 (可读可执行) 写为 7 (可读写可执行),同时把 p_memsz 改大一些。这样 padding 就可以直接加在后面,不需要再拷贝到栈上。

  3. 分支之间共用指令

    注意最内层的那一串 if-else:有两个分支都用到 b^c,还有两个分支计算 f 的最后一步是 ^c,还两个分支计算 f 的最后一步是 ^d。这里代码都可以共用,不仅可以省掉几个字节,更重要的是,分支代码码变得更短以后,更容易塞到文件头里面。

  4. 指令选择

    尽量选用早期 x86 就有的指令,一般编码会比较短。很多不常用的指令,只因为生得早,占了好坑,都只要一两个字节。

    例如:lodsb 只要一个字节,而 mov al,[rsi]; inc rsi 要好几个。

    又如,loop addr 指令大致相当于 dec ecx; jnz addr,也少了好几个字节。没有哪个现代编译器会主动生成这个指令,实际上 Intel 也不建议用,较新的 CPU 里没有为这个指令做优化,性能偏低。但对本题却相当合适。

    拿不准的时候多试几次,用 objdump 看哪个短。

  5. 寄存器的选择

    优先使用从 32 位时代沿袭下来的 8 个寄存器 (rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp),避免使用 r8-r15。对于很多指令,使用 r8-r15 会多占一个字节。例如:

     0:   41 83 e0 01             and    r8d,0x1
    4: 83 e0 01 and eax,0x1
  6. 用最短的代码赋值小常量给寄存器

    对 0 值,对寄存器异或自身:xor eax, eax。这个很常见,减少代码体积,也不损失性能,Intel 也推荐这样写。

    对于非 0 值,下面的写法就损失性能了,但对本题有用。

    将常量 1 赋给 ebx,最自然的写法 5 个字节:

    0:   bb 01 00 00 00          mov    ebx,0x1

    这样写只要 4 个字节(如果知道 ebx 的高位已经是 0,则只需后一指令,2 个字节):

    0:   31 db                   xor    ebx,ebx
    2: b3 01 mov bl,0x1

    这样写只要 3 个字节:

    0:   6a 01                   push   0x1
    2: 5b pop rbx
  7. 32 位 or 64 位寄存器

    x86-64 有一个特性,目标操作数是 32 位寄存器时,会同时清空对应 64 位寄存器的高 32 位。所以 xor eax, eax 与 xor rax, rax 等价;mov eax, 1 也与 mov rax, 1 等价。使用 32 位寄存器常常可以省一个字节(r8-r15 除外):

    0:   31 c0                   xor    eax,eax
    2: 48 31 c0 xor rax,rax

    但注意,在作为地址时,情况相反,64 位少一个字节:

    0:   67 8d 43 08             lea    eax,[ebx+0x8]
    4: 8d 43 08 lea eax,[rbx+0x8]
  8. 进程初始状态

    网上说 Linux 进程启动时,除了 RCX 以外,其他寄存器的值都不确定。但实测拿到的都是 0,所以省略对寄存器清 0 的操作。

    (有的资料说其他寄存器会继承 execve 之前的值,懒得去验证,不过对此存疑。如果操作系统不清 0,这会是一个安全漏洞,父进程可能无意中漏泄信息给子进程。)

  9. 使用 16 位地址

    ELF 的常见初始虚拟地址是诸如 0x8048000 这样的值,把它改成 0x1000,这样两个字节可以放下地址。

    不能比 0x1000 更小了,因为 0x1000 == 4096 是一个页面的大小,在最前面至少要留一个页面,否则 NULL 会成为有效指针。

  10. 栈优化

    栈涉及到内存操作,比用寄存器慢。然而 push、pop 作为元老级的 8086 指令,编码超级短,前 8 个寄存器的 push、pop 只要一个字节。

    0:   50                      push   rax
    1: 5b pop rbx
    2: 48 89 c3 mov rbx,rax

    在这个例子里,出栈再入栈竟然比 mov 少一个字节!

  11. 浮点运算

    现在 Intel 推荐用 SSE/AVX 来做浮点运算,但对本题还是 x87 香,计算 sin、取绝对值都是一条指令的事情(两个字节)。

    x87 寄存器是栈式的,写复杂的算式比较麻烦。好在本题的式子并不复杂,下面几行代码就可以计算出 (fabs(sin(i + 1)) * 4294967296.0),其中 [rbx-0x14] 指向一个预存了 4294967296.0 的地址。

     10d4:       50                push   rax                    ; i+1 入栈
    10d5: db 04 24 fild DWORD PTR [rsp] ; 转成浮点数,放到st0
    10d8: d9 fe fsin ; sin
    10da: d9 e1 fabs ; abs
    10dc: d8 4b ec fmul DWORD PTR [rbx-0x14] ; 乘以 4294967296
    10df: dd 0c 24 fisttp QWORD PTR [rsp] ; 转成整数,写到栈顶
    10e2: 5a pop rdx ; 出栈,读到结果
  12. pext 指令

    C++ 代码里有一处 (i / 16) * 4 + (i % 4),很适合用 pext 指令来实现。pext 是一条 BMI 2 指令,可以简便地从源操作数里抽一些不连续的位出来。

    假设 i 放在 eax,那么用 pext ecx, eax, bit_mask (bit_mask = 0b00110011) 就可以从 i 里面提取出第 0、1、4、5 位,正好就是 (i / 16) * 4 + (i % 4) 的值。

  13. 使用 near 跳转

    jmp、jcc 都有 8 位偏移和 32 位偏移两个版本。32 位太浪费,尽量调整代码块的位置,让所有跳转都变成 8 位(跳转范围在 -128 至 127 字节之间)。实在避免不了的,用两条短 jmp 中转一下都能节省一个字节。

  14. 寻址优化

    x86-64 提供了相对 rip 的寻址,为位置无关代码(PIC)提供了很大便利。又由于它比绝对寻址短,所以编译器通常在非 PIC 代码中也会使用它。

    但遗憾的是,它始终使用 32 位偏移量,太浪费。可以考虑固定使用一个寄存器保存一个地址,后面的寻址都相对它进行,并设法将偏移保持在 8 位的范围内(-128 至 127)。

    对比以下三条指令的长度:

    0:   8b 43 02                mov    eax,DWORD PTR [rbx+0x2]
    3: 8b 05 02 00 00 00 mov eax,DWORD PTR [rip+0x2]
    9: 8b 04 25 02 00 00 00 mov eax,DWORD PTR [0x2]
  15. 调用函数

    call 指令没有 8 位偏移的版本,32 位偏移很浪费。先将函数地址装到一个寄存器,再 call 这个寄存器,只要两个字节:

    0:   ff d3                   call   rbx

最后撞三个字节

写完代码以后,发现 ELF 头部还剩两个可以自由修改的字节。代码里还有一些等效的指令,例如,当已知 eax 和 ecx 的值相等时,下面四条指令等价:

lea     ecx, [rax+rax*4+1]
lea ecx, [rax+rcx*4+1]
lea ecx, [rcx+rax*4+1]
lea ecx, [rcx+rcx*4+1]

又如,在判断循环条件时,下面代码里的 jb 可以替换为 jne:

inc   eax
cmp al, 64
jb _loop_md5

于是,通过给 nasm 增加参数 -Dalternative=...,并在代码里使用 %if alternative = ... 来选择使用哪一种等价的写法。

再写一个额外的程序来遍历头部两个字节的值,以及代码中这些有 alternative 的地方的指令选择,使得计算出的 MD5 的中间结果的最后三个字节是 0x80 0x00 0x00,即与 MD5 padding 的前三个字节相同,这样就可以从文件内容中省去这三个字节。

代码

完整代码:

bits 64

; 定义“变量”对应的寄存器
; R_ 表示 64 位,r_ 表示对应的 32 位,_w 表示低 16 位, _l 表示低 8 位
%define r_base ebx ; 始终指向$$+offs
%define R_base rbx
%define r_base_w bx
;%define r_a r8d ; a,b,c,d 在内层循环中保存MD5状态
;%define R_a r8 ; r_a 现在不占用寄存器了,移到栈顶 [rsp]
%define r_b edi
%define R_b rdi
%define r_c ebp
%define R_c rbp
%define r_d edx
%define R_d rdx
%define r_i eax ; 内层循环下标
%define R_i rax
%define r_i_l al
%define r_f esi ; 内层循环中的变量 F
; ecx用作临时变量

; 一般是使用 0x8048000,故意改成一个很小的数,以便两个字节可以放下地址
%define base 0x1000
org base
ehdr: ; Elf64_Ehdr
db 0x7F, "ELF", 2, 1, 1, 0 ; e_ident
; times 8 db 0

; 这个代码块正好8字节,放这里
_16_to_32:
; if (16 <= i && i < 32) {
; g = 5*i + 1;
; f = ((b ^ c) & d) ^ c; // (d & b) | ((~d) & c);
; }
%if alternative = 1 ; alternative用于控制生成多种等效的代码,fill.py会对它们进行遍历,
; 找到使MD5中间结果最后几个字节恰好等于 0x80 0x00 0x00 的组合
lea ecx, [R_i+R_i*4+1]
%elif alternative = 2
lea ecx, [R_i+rcx*4+1]
%elif alternative = 3
lea ecx, [rcx+R_i*4+1]
%else
lea ecx, [rcx+rcx*4+1]
%endif
and r_f, r_d
jmp _reuse_code ; _16_to_32 与 _32_to_48 最后一条指令相同,jmp过去省两个字节
times 8 - ($ - _16_to_32) db 0
; end
dw 2 ; e_type
dw 62 ; e_machine
;dd 1 ; e_version (modifiable)
_0x1p32 dd 4294967296.0 ; pow(2.0, 32)
dq _start ; e_entry
dq phdr - $$ ; e_phoff
;dq 0 ; e_shoff (modifiable)
;dd 0 ; e_flags (modifiable)
;dw ehdrsize ; e_ehsize (modifiable)

; 14个可以修改的字节,放一个函数在此(13字节)
; 将al低4位转为1位16进制
_make_hex:
and eax, 15
cmp al, 10
jb .lt10
add al, 'a' - '0' - 10
.lt10:
add al, '0'
stosb
ret
; 剩一个字节,放pext的mask
%if alternative = 1
_pext_mask db 0b00110011
%elif alternative = 2
_pext_mask db 0b01110011
%elif alternative = 3
_pext_mask db 0b10110011
%else
_pext_mask db 0b11110011
%endif
times 14-($-_make_hex) db 0

dw phdrsize ; e_phentsize

;; overlap the last 8 bytes of ehdr with phdr
phdr: ; Elf64_Phdr
dd 1 ; p_type = 1
; e_phnum (word, 1)
; e_shentsize (word, modifiable)
db 7 ; p_flags=7 (readable, writable, executable)
db 0 ; 这个字节留给fill.py修改 (p_flags只要最低1字节对即可,高位随便改)
_context_ptr dw base + (_context - $$) ; e_shnum (word, modifiable)
; e_shstrndx (word, modifiable)

ehdrsize equ $ - ehdr

dq 0 ; p_offset
dq $$ ; p_vaddr
;;dq $$ ; p_paddr (modifiable)
_0_to_16: ; 这个代码块正好8字节,放这里
; if (i < 16) {
; g = i;
; f = ((c ^ d) & b) ^ d; // (b & c) | ((~b) & d);
; }
mov r_f, r_c
xor r_f, r_d
and r_f, r_b
jmp _reuse_code_2 ; _0_to_16 与 _48_to_64 最后一条指令相同,jmp过去省两个字节
times 8-($-_0_to_16) db 0

dq filesize ; p_filesz
;dq filesize+512 ; p_memsz
; 挪用p_memsz用于存一些其他数据,只要这里填的值至少比filesize大一些,但又不要过于
; 巨大以免运行错误
_16_to_32_relay: jmp short _16_to_32 ; 中转向_16_to_32的跳转,便得两处跳转都使用8位偏移量
_compute_start_ptr dd ($$ + filesize - filesize % 64)
times (8 - ($ - _16_to_32_relay)) db 0

;dq 0x1000 ; p_align
;; p_align是phdr的最后8个字节,对静态链接的代码没什么用,省去
phdrsize equ $ + 8 - phdr

_shift db 7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21

%assign start_of_code base + ($ - $$)
%warning code: start_of_code

_48_to_64:
; if (i >= 48) {
; g = 7*i;
; f = c ^ (b | (~d));
; }
%if alternative = 1
imul ecx, r_i, 7
%else
imul ecx, 7
%endif
mov r_f, r_d
not r_f
or r_f, r_b
_reuse_code:
xor r_f, r_c
jmp _join

; R_base 偏移 offs 字节,指向 _make_hex
offs equ _make_hex - $$

; 程序入口
_start:
mov r_base_w, base + offs ; mov r_base, $$ + offs
; 文档说进程启动时rdi的值未定,但实测至少tlinux2
; 保证了它是0
mov esi, [R_base - offs - $$ + _context_ptr]
; 本来这里应该用movzx读2个字节的,但正好后面2个字节也是0,就用mov省一个字节
push rsi
; MD5 padding
mov byte [rsi + _end - _context], 0x80
mov word [rsi + _end - _context + paddingsize - 8], filesize*8

; uint32_t a = hash[0];
; uint32_t b = hash[1];
; uint32_t c = hash[2];
; uint32_t d = hash[3];

push qword [rsi]
mov r_b, [rsi + 4]
mov r_c, [rsi + 8]
mov r_d, [rsi + 12]

; for (int i = 0; i < 64; ++i) {
; xor r_i, r_i ; 文档说启动时rax的值未定,实测至少tlinux2上进程启动时eax=0
_loop_md5:

; 有两个分支用到 b^c,在分支前先算出来放到f,省两个字节
mov r_f, r_b ; f = b ^ c
xor r_f, r_c
mov ecx, r_i ; g = i

cmp r_i_l, 16
jb _0_to_16
cmp r_i_l, 32
jb _16_to_32_relay
cmp r_i_l, 48
jae _48_to_64

_32_to_48:
; if (32 <= i && i < 48) {
; g = 3*i + 5;
; f = b ^ c ^ d;
; }
%if alternative = 1
lea ecx, [R_i+R_i*2+5]
%elif alternative = 2
lea ecx, [R_i+rcx*2+5]
%elif alternative = 3
lea ecx, [rcx+R_i*2+5]
%else
lea ecx, [rcx+rcx*2+5]
%endif
_reuse_code_2:
xor r_f, r_d

_join:
; f += a
; a = d;
; (d := garbage)
xchg r_d, [rsp]
add r_f, r_d

; f += in[g % 16];
and ecx, 15
mov r_d, [R_base - offs - $$ + _compute_start_ptr]
add r_f, [R_d + rcx*4]

; int shift_val = _shift[(i / 16) * 4 + (i % 4)];
pext ecx, r_i, [R_base-offs-$$+_pext_mask] ; _pext_mask的高24位不是0,不过不影响,因为r_i高位全是0
mov cl, [R_base-offs-$$+_shift+rcx] ; [_shift + rcx]

; K[i] := floor(2^32 × abs (sin(i + 1)))
inc r_i
push R_i
fild dword [rsp]
fsin
fabs
fmul dword [R_base-offs-$$+_0x1p32] ; * 2^32
fisttp qword [rsp]

; f += K[i]
pop R_d ; 把r_d当作临时寄存器用一下,pop出来的是刚才fisttp写入[rsp]的值,即K[i]
add r_f, r_d

; f = (f << shift_val) | (f >> (32 - shift_val));
rol r_f, cl

; d = c;
; c = b;
; b += f;
mov r_d, r_c
mov r_c, r_b
add r_b, r_f

; }
cmp r_i_l, 64
%if alternative = 1
jb _loop_md5
%else
jne _loop_md5
%endif

; hash[0] += a;
; hash[1] += b;
; hash[2] += c;
; hash[3] += d;
pop rax ; A
pop rsi ; _context
add [rsi], eax
add [rsi+4], r_b
add [rsi+8], r_c
add [rsi+12], r_d

; PRINT HEX
;xor ecx, ecx ; 从前面循环里出来,ecx高位还是0
mov cl, 16 ; mov ecx, 16
lea edi, [rsi+16]

.loop_print_hex:
mov al, [rsi]
shr eax, 4
call R_base ; call _make_hex
lodsb
call R_base ; call _make_hex
loop .loop_print_hex

;rsi is already buffer of hex string
mov al, 1 ; mov eax, 1 (write) (eax高位已经是0)
mov edi, eax ; mov edi, 1 (1 = stdout)
%if alternative = 1
lea edx, [rax+31] ; mov edx, 32
; 用lea比mov短一点
%else
lea edx, [rdi+31]
%endif
syscall
; 返回后eax=32

; EXIT
%if alternative = 1
mov al, 231 ; exit_group (eax高位已经是0)
%elif alternative = 2
add al, 231-32 ; 同上
%elif alternative = 3
sub al, 32-231 ; 同上
%elif alternative = 4
mov al, 60 ; exit (eax高位已经是0)
%elif alternative = 5
add al, 60-32 ; 同上
%else
sub al, 32-60 ; 同上
%endif
xor edi, edi
syscall

%if ($ - $$) % 64 > 64 - 13 - 9
%assign wasted_padding 64 - ($ - $$) % 64
%warning Wasted padding bytes: wasted_padding
align 64
%endif

; _context 的初始内容由 fill.py 填入
_context:
times 13 db 0 ; 最后三个字节由fill.py保证正好是0x80 0x00 0x00 (和MD5的padding一致)


_end:

filesize equ $ - $$
main_loops equ (filesize + 9 + 63) / 64 ; MD5算法循环次数
paddingsize equ 64 * main_loops - filesize ; 原始数据后需要附加的字节数

%assign filesize filesize
%warning File size: filesize

光凭这个 asm 还不够,还要配合碰撞的脚本才能编译出来正确的二进制:

  • README.md:解题描述

  • build.ninja:编译脚本

  • main.asm: 主程序

  • fill.py: 用于碰撞 3 个字节的脚本

  • fill_helper.cpp: fill.py 的辅助代码(用 C++ 加速)

  • md5.out: 最终的 299 字节可执行文件

  • cpp_reference.cpp:C++ 参考实现

最终文件 md5.out 。从源码编译需要先安装 Python 3、ninja、nasm,然后运行 ninja 命令,等 10~20 分钟(取决于机器性能),就能得到 md5.out。

感兴趣的小伙伴,可以在公众号回复“md5”获取完整实现。


大神解法,我是跪着看完了,欢迎小伙伴在留言区打上666,膜拜一下大佬,

如果文章给你长见识了,那就一键三连继续支持,点赞和在看超过30,我就放另辟蹊径的野路子解法:

绝对让你感叹脑洞如此之大,让人望其项背。


推荐阅读


最小MD5挑战赛,你敢来战吗?

顶级C程序员之路

一个奇葩的网络问题

C++模版的本质

原文作者:极客重生;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2021-01-23

  • 博文量
    165
  • 访问量
    67588