【解説】どこでも動くC言語プログラム「Cosmopolitan Libc」について

今回は小ネタとして、C言語でありながらbuild-anyware run-anywareを目指すプロジェクト「Cosmopolitan Libc」について解説します。
某30億のデバイスで走る言語の Write once, run anywhere みたいなフレーズですね

まとめ

  • C言語の実行速度を保ちながらbuild-anyware run-anywareを実現
    • 現在はLinux/Mac/Windows/FreeBSD/OpenBSD/NetBSD/BIOS + x64/arm64で動作
  • 各プラットフォーム用のバイナリを内部に保持しており、環境によって使い分ける仕様
    • ヘッダを工夫してPE(DOS)/MBR/shell scriptのどれとも取れるようにしている
  • 公式チュートリアル: https://justine.lol/cosmopolitan/index.html
    • パッケージ内のバイナリも run-anyware を実現している模様

詳細

生成されたバイナリを調査

以下のC言語プログラムをビルドして、生成されたバイナリを確認します。

#include <stdio.h>

int main(int argc, char *argv[]) {
  printf("hello world\n");
}
$ ./cosmocc/bin/cosmocc main.c
$ ls
a.out  a.out.aarch64.elf  a.out.com.dbg main.c

file

$ file a.out
a.out: DOS/MBR boot sector; partition 1 : ID=0x7f, active, start-CHS (0x0,0,1), end-CHS (0x3ff,255,63), startsector 0, 4294967295 sectors

どうやら DOS/MBR として認識されている模様。
MBR (Master Boot Record) はBIOSの実行方法の1種で、ディスクの1セクタ目(先頭512バイト)にブートレコードを記録する方法です。

BIOSは起動時に1セクタ目の末尾2バイトが0x55 0xAAだった場合、その512バイトを0x7C00~0x7DFFにロード、スタックを0x8000にセットして0x7C00から実行を始めます。
そのため、このファイルはディスクイメージとして利用すればBIOSからも実行可能ということです。

xxd

実際にxxdでバイナリをダンプしてみるとこんな感じ

$ xxd ./a.out | head -40
00000000: 4d5a 7146 7044 3d27 0a0a 0010 00f8 0000  MZqFpD='........
00000010: 0000 0000 0001 0008 4000 0000 0000 0000  ........@.......
00000020: 0000 0000 0000 0000 2720 3c3c 276a 7573  ........' <<'jus
00000030: 7469 6e65 7a62 326d 6e72 270a 580a 0100  tinezb2mnr'.X...
00000040: b240 eb00 eb14 9090 eb06 4883 ec08 31d2  .@........H...1.
00000050: bd00 00eb 05e9 1d15 0000 fc0f 1f87 3ee0  ..............>.
00000060: bf00 7031 c98e c1fa 8ed7 89cc fb0e 1fe8  ..p1............
00000070: 0000 5e81 ee72 00b8 0002 5050 0731 ffb9  ..^..r....PP.1..
00000080: 0002 f3a4 0f1f 87d2 ffea 8e20 0000 8ed9  ........... ....
00000090: b900 1bb8 5000 8ec0 31c0 31ff f3aa 80fa  ....P...1.1.....
000000a0: 4074 1ae8 1c00 07b0 0131 c930 f6bf 6001  @t.......1.0..`.
000000b0: e86f 008c c683 c620 8ec6 4f75 f3ea e024  .o..... ..Ou...$
000000c0: 0000 5352 b416 cd13 7319 31c0 cd13 7246  ..SR....s.1...rF
000000d0: b801 02b9 0100 b600 bb00 028e c331 dbcd  .............1..
000000e0: 1372 33b4 08cd 1372 2d88 cf80 e73f 80e1  .r3....r-....?..
000000f0: c0d0 c1d0 c186 cd1e 061f 31f6 8ec6 be18  ..........1.....
00000100: 1d87 f7a5 a5a5 a5a5 a41f 93ab ae91 ab92  ................
00000110: ab58 aa92 5bc3 5a80 f280 31c0 cd13 72f7  .X..[.Z...1...r.
00000120: eba1 5051 86cd d0c9 d0c9 08c1 31db b001  ..PQ........1...
00000130: b402 cd13 5958 7216 fec0 3a06 241d 760d  ....YXr...:.$.v.
00000140: b001 fec6 3a36 291d 7603 30f6 41c3 5031  ....:6).v.0.A.P1
00000150: c0cd 1358 ebcc 89fe ac84 c074 09bb 0700  ...X.......t....
00000160: b40e cd10 ebf2 c357 bf99 24e8 e8ff 5fe8  .......W..$..._.
00000170: e4ff bfa1 24e8 deff f390 ebfc b904 00be  ....$...........
00000180: 0004 ad85 c074 0b51 5697 beb0 24e8 0500  .....t.QV...$...
00000190: 5e59 e2ee c389 fa85 d274 1452 5631 c9b1  ^Y.......t.RV1..
000001a0: 0301 caac 5e0c 80ee 5aac ee42 4979 fac3  ....^...Z..BIy..
000001b0: 0000 0000 0000 0000 0000 0000 0000 8000  ................
000001c0: 0100 7fff ffff 0000 0000 ffff ffff 0000  ................
000001d0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000001e0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000001f0: 0000 0000 0000 0000 0000 0000 0000 55aa  ..............U.
00000200: 0a6a 7573 7469 6e65 7a62 326d 6e72 0a23  .justinezb2mnr.#
00000210: 2722 0a0a 6f3d 2428 636f 6d6d 616e 6420  '"..o=$(command
00000220: 2d76 2022 2430 2229 0a5b 2078 2224 3122  -v "$0").[ x"$1"
00000230: 2021 3d20 782d 2d61 7373 696d 696c 6174   != x--assimilat
00000240: 6520 5d20 2626 2074 7970 6520 6170 6520  e ] && type ape
00000250: 3e2f 6465 762f 6e75 6c6c 2032 3e26 3120  >/dev/null 2>&1
00000260: 2626 2065 7865 6320 6170 6520 2224 6f22  && exec ape "$o"
00000270: 2022 2440 220a 743d 2224 7b54 4d50 4449   "$@".t="${TMPDI

たしかに1セクタ目の終わりが0x55aaになっているのがわかります。
一方で、バイナリはMZから始まっており、これはPEヘッダであるためPEとしても見れます。
さらにバイナリをよく見ると

MZqFpD='................@...............'<<'tinezb2mnr'
[MBR Data]
justinezb2mnr
#'"
o=$(command -v "$0").[ x"$1" != x--assimilate ] && type ape >/dev/null 2>&1 && exec ape "$o" "$@"

のようなフォーマットをしており、シェルスクリプトとして扱うこともできます。
PEヘッダ内にある実行に無関係な部分で工夫して、このようなフォーマットを作り上げるのはなかなか芸術的ですね…

readpe

readpeでDOSヘッダーを覗いてみるとこんな感じ。

$ readpe a.out
DOS Header
    Magic number:                    0x5a4d (MZ)
    Bytes in last page:              18033
    Pages in file:                   17520
    Relocations:                     10045
    Size of header in paragraphs:    2570
    Minimum extra paragraphs:        4096
    Maximum extra paragraphs:        63488
    Initial (relative) SS value:     0
    Initial SP value:                0
    Initial IP value:                0x100
    Initial (relative) CS value:     0x800
    Address of relocation table:     0x40
    Overlay number:                  0
    OEM identifier:                  0
    OEM information:                 0
    PE header offset:                0x10a58
COFF/File header
    Machine:                         0x8664 IMAGE_FILE_MACHINE_AMD64
    Number of sections:              3
    Date/time stamp:                 1550062187 (Wed, 13 Feb 2019 12:49:47 UTC)
    Symbol Table offset:             0
    Number of symbols:               0
    Size of optional header:         0x80
    Characteristics:                 0x223
    Characteristics names
                                         IMAGE_FILE_RELOCS_STRIPPED
                                         IMAGE_FILE_EXECUTABLE_IMAGE
                                         IMAGE_FILE_LARGE_ADDRESS_AWARE
                                         IMAGE_FILE_DEBUG_STRIPPED
Optional/Image header
    Magic number:                    0x20b (PE32+)
    Linker major version:            14
    Linker minor version:            15

現代のWindowsだとDOSヘッダーはPEヘッダーへのオフセット以外何も見られないので、フィールドを好き勝手使ってるようです。

objdump

BIOSで実行した場合、バイナリの0バイト目から命令として解釈されて実行されるのですが、これはどのような命令になるのでしょうか?objdumpで確認してみました。

ちなみにBIOS起動時は16bitモードなのでi8086で逆アセンブルする必要があります。

$ objdump -D -b binary -m i8086 --start-address=0 ./a.out

Disassembly of section .data:

00000000 <.data>:
       0:       4d                      dec    %ebp
       1:       5a                      pop    %edx
       2:       71 46                   jno    0x4a
       4:       70 44                   jo     0x4a
...
      4a:       48                      dec    %ax
      4b:       83 ec 08                sub    $0x8,%sp
      4e:       31 d2                   xor    %dx,%dx
      50:       bd 00 00                mov    $0x0,%bp
      53:       eb 05                   jmp    0x5a
...
      5a:       fc                      cld
      5b:       0f 1f 87 3e e0          nopw   -0x1fc2(%bx)
      60:       bf 00 70                mov    $0x7000,%di
      63:       31 c9                   xor    %cx,%cx
      65:       8e c1                   mov    %cx,%es
      67:       fa                      cli
      68:       8e d7                   mov    %di,%ss
      6a:       89 cc                   mov    %cx,%sp
      6c:       fb                      sti
      6d:       0e                      push   %cs
      6e:       1f                      pop    %ds
      6f:       e8 00 00                call   0x72
      72:       5e                      pop    %si
      73:       81 ee 72 00             sub    $0x72,%si
      77:       b8 00 02                mov    $0x200,%ax
      7a:       50                      push   %ax
      7b:       50                      push   %ax
      7c:       07                      pop    %es
      7d:       31 ff                   xor    %di,%di
      7f:       b9 00 02                mov    $0x200,%cx
      82:       f3 a4                   rep movsb %ds:(%si),%es:(%di)
      84:       0f 1f 87 d2 ff          nopw   -0x2e(%bx)
      89:       ea 8e 20 00 00          ljmp   $0x0,$0x208e
      8e:       8e d9                   mov    %cx,%ds
      90:       b9 00 1b                mov    $0x1b00,%cx
      93:       b8 50 00                mov    $0x50,%ax
      96:       8e c0                   mov    %ax,%es
      98:       31 c0                   xor    %ax,%ax
      9a:       31 ff                   xor    %di,%di
      9c:       f3 aa                   rep stos %al,%es:(%di)
      9e:       80 fa 40                cmp    $0x40,%dl
      a1:       74 1a                   je     0xbd
      a3:       e8 1c 00                call   0xc2
      a6:       07                      pop    %es
      a7:       b0 01                   mov    $0x1,%al
      a9:       31 c9                   xor    %cx,%cx
      ab:       30 f6                   xor    %dh,%dh
      ad:       bf 60 01                mov    $0x160,%di
      b0:       e8 6f 00                call   0x122
      b3:       8c c6                   mov    %es,%si
      b5:       83 c6 20                add    $0x20,%si
      b8:       8e c6                   mov    %si,%es
      ba:       4f                      dec    %di
      bb:       75 f3                   jne    0xb0
      bd:       ea e0 24 00 00          ljmp   $0x0,$0x24e0

      c2:       53                      push   %bx
      c3:       52                      push   %dx
      c4:       b4 16                   mov    $0x16,%ah
      c6:       cd 13                   int    $0x13
      c8:       73 19                   jae    0xe3
      ca:       31 c0                   xor    %ax,%ax
      cc:       cd 13                   int    $0x13

“MZqFpD”という文字列は、どちらも0x4aにジャンプさせるためのものだったんですね。
その後0x4aから0x5aにジャンプして、後続のセクタをメモリ上に読み出します。

0xa3でcall 0xc2とあり、0xc2の関数ではint 0x13を呼び出しています。
これは後続のセクタをメモリ上に読み出すようBIOSに要求する割り込み命令で、実行したいバイナリデータをメモリ上に展開してもらいます。

その後0xbdでロングジャンプを発行し、本来の実行したいバイナリにジャンプするという形です。よくできてますね本当に…

shell script (Linux/BSD/Mac)

Unix系のOSではPEやDOSを binfmt_misc に登録しない限り、シェルスクリプトとして解釈され実行されます。生成されたバイナリをシェルスクリプトとして解釈するとこんな感じに見えます。

o=$(command -v "$0")
[ x"$1" != x--assimilate ] && type ape >/dev/null 2>&1 && exec ape "$o" "$@"
t="${TMPDIR:-${HOME:-.}}/.ape-1.10"
[ x"$1" != x--assimilate ] && [ -x "$t" ] && exec "$t" "$o" "$@"
m=$(uname -m 2>/dev/null) || m=x86_64
if [ ! -d /Applications ]; then
if [ x"$1" = x--assimilate ]; then
if [ "$m" = x86_64 ] || [ "$m" = amd64 ]; then
exec 7<> "$o" || exit 121
printf '\177ELF\2\1\1\11\0\0\0\0\0\0\0\0\2\0>\0\1\0\0\0w\25@\0\0\0\0\0\510\013\000\000\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\1
00\0\70\0\006\000\0\0\0\0\0\0' >&7
exec 7<&-
fi
exit
...
if [ ! -d /Applications ]; then
if [ "$m" = x86_64 ] || [ "$m" = amd64 ]; then
mkdir -p "${t%/*}" ||exit
dd if="$o" skip=362704     count=4180       bs=1 2>/dev/null | gzip -dc >"$t.$$" ||exit
chmod 755 "$t.$$" ||exit
mv -f "$t.$$" "$t" ||exit
exec "$t" "$o" "$@"
fi

大きくわけて2つの処理があります。

1つ目が –assimilate オプションがついている場合。
これはバイナリのヘッダをELFヘッダに書き換えることで、バイナリを完全なELFファイルに変換します。

$ file a.out
a.out: DOS/MBR boot sector; partition 1 : ID=0x7f, active, start-CHS (0x0,0,1), end-CHS (0x3ff,255,63), startsector 0, 4294967295 sectors

$ bash -x ./a.out --assimilate
+ MZqFpD='

@'
++ command -v ./a.out
+ o=./a.out
+ '[' x--assimilate '!=' x--assimilate ']'
+ t=/home/mic/.ape-1.10
+ '[' x--assimilate '!=' x--assimilate ']'
++ uname -m
+ m=x86_64
+ '[' '!' -d /Applications ']'
+ '[' x--assimilate = x--assimilate ']'
+ '[' x86_64 = x86_64 ']'
+ exec
+ printf '\177ELF\2\1\1\11\0\0\0\0\0\0\0\0\2\0>\0\1\0\0\0w\25@\0\0\0\0\0\510\013\000\000\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\100\0\70\0\006\000\0\0\0\0\0\0'
+ exec
+ exit

$ file a.out
a.out: ELF 64-bit LSB executable, x86-64, version 1 (FreeBSD), for OpenBSD, statically linked, no section header

$ readelf -h a.out
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 09 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - FreeBSD
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x401577
  Start of program headers:          2888 (bytes into file)
  Start of section headers:          0 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         6
  Size of section headers:           0 (bytes)
  Number of section headers:         0
  Section header string table index: 0


2つ目として –assimilate オプションがついてない場合、ローダーを別ファイルとしてバイナリから切り出して、そのローダー経由でバイナリを実行します。

$ bash -x ./a.out
+ MZqFpD='

@'
++ command -v ./a.out
+ o=./a.out
+ '[' x '!=' x--assimilate ']'
+ type ape
+ t=/home/mic/.ape-1.10
+ '[' x '!=' x--assimilate ']'
+ '[' -x /home/mic/.ape-1.10 ']'
++ uname -m
+ m=x86_64
+ '[' '!' -d /Applications ']'
+ '[' x = x--assimilate ']'
+ '[' x = x--assimilate ']'
+ '[' '!' -d /Applications ']'
+ '[' x86_64 = x86_64 ']'
+ mkdir -p /home/mic
+ dd if=./a.out skip=362704 count=4180 bs=1
+ gzip -dc
+ chmod 755 /home/mic/.ape-1.10.489659
+ mv -f /home/mic/.ape-1.10.489659 /home/mic/.ape-1.10
+ exec /home/mic/.ape-1.10 ./a.out
hello world

apeローダーの中身はここで確認できます。
https://github.com/jart/cosmopolitan/blob/master/ape/loader.c

glibcとの比較

ファイルサイズ

$ gcc --static -s -o a.out.glibc main.c
$ ll -h a.out a.out.glibc
-rwxr-xr-x. 1 400K Feb 18 19:37 a.out
-rwxr-xr-x. 1 761K Feb 18 19:37 a.out.glibc

glibcより軽いという結果になりました。
自前でlibcを実装しているようで、ライブラリ関数がglibcより簡潔になってることが理由だと思います。
https://justine.lol/cosmopolitan/documentation.html

strace

$ ./a.out --assimilate
$ strace ./a.out
execve("./a.out", ["./a.out"], 0x7ffd9c0db210 /* 45 vars /) = 0 getpid() = 489470 getrlimit(RLIMIT_STACK, {rlim_cur=81921024, rlim_max=RLIM64_INFINITY}) = 0
arch_prctl(ARCH_SET_GS, 0x42d680) = 0
mmap(0x6fe000000, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x6fe000000
readlinkat(AT_FDCWD, "/proc/self/exe", "/home/mic/a.out", 1023) = 15
openat(AT_FDCWD, "/home/mic/a.out.dbg", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/home/mic/a.out.com.dbg", O_RDONLY|O_CLOEXEC) = 3
fcntl(3, F_GETFD) = 0x1 (flags FD_CLOEXEC)
lseek(3, 0, SEEK_END) = 1056430
mmap(NULL, 1056430, PROT_READ, MAP_SHARED, 3, 0) = 0x7f4321713000
munmap(0x7f4321713000, 1056430) = 0
close(3) = 0
write(1, "hello world\n", 12hello world
) = 12
exit_group(0) = ?
+++ exited with 0 +++
$ strace ./a.out.glibc
execve("./a.out.glibc", ["./a.out.glibc"], 0x7ffc62b4b980 /* 45 vars */) = 0
arch_prctl(0x3001 /* ARCH_??? */, 0x7ffe2a75cb70) = -1 EINVAL (Invalid argument)
brk(NULL)                               = 0x1755000
brk(0x1755d80)                          = 0x1755d80
arch_prctl(ARCH_SET_FS, 0x1755380)      = 0
set_tid_address(0x1755650)              = 489474
set_robust_list(0x1755660, 24)          = 0
rseq(0x1755d20, 0x20, 0, 0x53053053)    = 0
uname({sysname="Linux", nodename="localhost.localdomain", ...}) = 0
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
readlink("/proc/self/exe", "/home/mic/a.out.glibc", 4096) = 21
getrandom("\xe5\x1c\xb9\x81\x43\x9f\x1e\xa3", 8, GRND_NONBLOCK) = 8
brk(NULL)                               = 0x1755d80
brk(0x1776d80)                          = 0x1776d80
brk(0x1777000)                          = 0x1777000
mprotect(0x4ae000, 16384, PROT_READ)    = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0), ...}) = 0
write(1, "hello world\n", 12hello world
)           = 12
exit_group(0)                           = ?
+++ exited with 0 +++

呼び出しているシステムコールも結構違いますね。

感想

すごく面白いなと思う一方で、悪用できそうだなと思ってしまいました…

大学院時代にマルウェアの研究をしていたのですが、マルウェアって動作先の環境がわからないので一旦シェルスクリプトを走らせて環境に合わせたバイナリを落としてくるってのが多いんですよね。
なんならこういう複数のプラットフォームで動作させるマルウェアの研究を見た気がします(アンチウィルスソフトの撹乱にも有効だったはずです)

話がそれましたが、非常に芸術的で面白い技術だと思いました。
いつか使ってみたいけど、本番環境で使うのはちょっと怖いかな…

参考サイト

シェアする

  • このエントリーをはてなブックマークに追加

フォローする