Представьте машину, на которой хочется собрать что-то исполняемое - но почти ничего нельзя.
Нет сети. Нет USB. Нет компилятора. Нет интерпретаторов вроде Python. Возможно, даже нет привычных утилит (dd, xxd, objdump, hexdump). Есть только shell и встроенные команды.
Звучит как шутка, пока это не становится реальным сценарием:
ограниченный удалённый shell или recovery shell на сломанном сервере,
корпоративная "запечатанная" машина, где shell есть, а установка софта запрещена,
учебный/экзаменационный стенд, где специально оставили только минимальный набор,
legacy/embedded-система, где есть /bin/sh, но toolchain нет и не будет.
Чтобы "освободить" такую машину от ограничений, нам нужно, как минимум, научиться создавать исполняемые файлы, возможно, даже местами ассемблируя в уме. Это не так сложно как раньше, до godbolt и chatgpt, вам просто понадобилось бы запомнить логику ассемблирования и некоторые распространенные инструкции, или хотя бы иметь листочек с таблицей опкодов. Сейчас его заменяет мобильный телефон, умные часы, или, в тяжелых случаях, микронаушник и друг в машине на ближайшей парковке. И "аппаратура при нем... при нем...".
Следующим шагом можно показать, как превратить маленький исполняемый файл в настоящий язык программирования, дополнить его фичами понадерганными из других языков, используя только клавиатуру и (не)здоровый шмоток изобретательности. Никаких чужих репозиториев, только то, чем нас одарила природа.
Итак, начнем!
Я буду считать что у нас есть Linux "без ничего", и нам надо:
записать ELF-заголовок,
записать program header,
положить машинный код, оставив его наглядным и поддерживаемым
получить исполняемый файл.
Это можно собрать руками, без as, ld, gcc и без "настоящего" ассемблера, с помощью самосборного "эмиттера" на sh, который в два прохода собирает ELF64 (x86_64 Linux), умеет работать с метками, относительными переходами (call/jmp/je) и абсолютными адресами (например, e_entry и p_filesz в ELF).
Чтобы пример был не совсем игрушечным, программа:
берёт адрес строки,
сама считает её длину циклом (аналог strlen, но на месте),
вызывает write(1, ptr, len),
завершает работу через exit(42).
Это уже полезнее для демонстрации, потому что в таком примере появляются:
метки вперёд и назад,
фиксапы rel32 для call, jmp, je,
RIP-relative адресация (lea rsi, [rip+msg]),
код и данные в одном бинарном образе,
необходимость двухпроходной сборки.
Почему нужен именно двухпроходный подход?
Если писать бинарник вручную, мы упираемся в проблемы:
адрес точки входа (e_entry) зависит от того, где в файле начнётся код;
размер сегмента (p_filesz) зависит от общего размера программы;
смещение до метки в "jmp rel32" зависит от длины всего, что находится между прыжком и целью;
"lea rsi, [rip+msg]" тоже требует вычислить смещение до метки msg.
Поэтому на первом проходе просто считаем размеры, двигаем счётчик позиции, запоминаем смещения меток, а пишем уже на втором - получается мини-ассемблер "на коленке".
Заводим:
Счётчик позиции P - Это текущий offset в файле. Любая директива или инструкция увеличивает P на свой размер. В первом проходе мы только считаем P, во втором - считаем и пишем.
Метки как переменные shell
Метки хранятся как значения наподобие:
L_code=...
L_msg=...
L_end=...
Буквально как shell-переменные. Это дёшево, сердито и достаточно эффективно.
3.По сути нужны два класса:
Абсолютный адрес / значение (abs64), например: e_entry = BASE + code или p_filesz = end
Относительное смещение (rel32), для call label, для jmp/je/jne label, для RIP-relative lea rsi, [rip+msg]
Этого уже хватает, чтобы писать нетривиальный код.
Также у нас есть функция, которая умеет записывать один байт. В чистом sh это делается через builtin printf и octal-escape: число 0..255 переводим в \ooo, печатаем printf '%b' "\ooo"
Это важный момент: мы не храним бинарные данные в shell-переменных (там есть проблемы с NUL), а пишем их прямо в файл.
Поверх этого строятся удобные кирпичики:
b - один байт
w2, w4, w8 - little-endian значения
h - эмиттер из hex-строки (7f454c46...)
db/dw/dd/dq - "директивы данных"
Этот h сильно уменьшает объём набираемого кода: вместо десятков вызовов emit8 можно задавать опкоды компактными hex-строками.
# emit one byte (low 8 bits) b() { n=$(( ($1) & 255 )) if [ "$T" = 2 ]; then o=$(printf '%03o' "$n") || fail "octal conversion" printf '%b' "\\$o" >&3 || fail "write" fi P=$((P + 1)) } w2() { v=$(( $1 )); b $v; b $((v >> 8)); } w4() { v=$(( $1 )); b $v; b $((v >> 8)); b $((v >> 16)); b $((v >> 24)); } w8() { v=$(( $1 )); b $v; b $((v >> 8)); b $((v >> 16)); b $((v >> 24)); b $((v >> 32)); b $((v >> 40)); b $((v >> 48)); b $((v >> 56)); } # emit bytes from hex string (e.g. h 7f454c46) h() { s=$1 while [ -n "$s" ]; do p=${s%"${s#??}"} # first two hex chars s=${s#??} b $((0x$p)) done }
Поверх эмиттера добавляется тонкий слой DSL, который можно расширять:
label name - определить метку
i_call target
i_jmp target
i_je target
i_mov_eax imm
i_mov_edi imm
i_syscall
i_ret
И директивы данных:
db, dw, dd, dq
strz
align
Нельзя сказать что это ассемблерный парсер, но этот код, по крайней мере, можно поддерживать. Нам всего-то нужно добиться компиляции следующего:
text label code label _start # точка входа # загружает адрес строки msg в rsi через lea ... [rip+msg]; i_lea_rsi msg # rsi = &msg # вызывает strlen0 - длина строки возвращается в rdx; i_call strlen0c # rdx = strlen(msg) via call # вызывает write(1, rsi, rdx); i_mov_eax 1 # SYS_write i_mov_edi 1 # fd=stdout i_syscall # exit(42) i_mov_eax 60 # SYS_exit i_mov_edi 42 i_syscall # Это маленькая функция, которая проходит по строке до NUL, # она нужна как причина иметь двухпроходную схему сборки # strlen0: input rsi=ptr, output rdx=len label strlen0 i_xor_edx_edx # rdx = 0 label len_loop # цикл: i_cmpb_rsi_rdx_0 # сравнить byte [rsi+rdx] с 0 i_je len_done # если ноль - выход i_inc_edx # иначе rdx++ и повтор i_jmp len_loop label len_done i_ret # возврат из функции
Для этого мы можем определять все что необходимо в примерно таком стиле:
# inc edx i_inc_edx() { h ffc2; } # mov eax, imm32 i_mov_eax() { h b8; w4 "$1"; }
Осталось собрать ELF64 ET_EXEC для x86_64 Linux с одним сегментом PT_LOAD.
Минимум, который нужен для запуска:
ELF header (64 байта):
magic 0x7F 'E' 'L' 'F'
класс ELF64
little-endian
тип ET_EXEC
архитектуру EM_X86_64
e_entry - адрес точки входа
e_phoff - смещение до program header
Program header (56 байт)
Один PT_LOAD, в котором лежит весь образ:
код,
данные,
строка.
Флаги сегмента - PF_R | PF_X (чтение + исполнение).
Секции (section headers) не нужны, но можно их добавить, если вдруг нужна совместимость с инструментами (objdump, линкер, дебаггер).
Фиксапы нужны в нескольких местах сразу.
e_entry. Точка входа - базовый виртуальный адрес (0x400000) плюс смещение метки codep_filesz и p_memsz. Размер сегмента - это размер всего файла до метки end.call/jmp/je. Переходы rel32 кодируются как:target - next_ipА next_ip - это позиция после поля смещения, которую мы узнаём только после первого прохода.lea rsi, [rip+msg]
Это тоже rel32-фиксап, только не для перехода, а для адресации данных через RIP.
В таком окружении это удобно:
не нужны внешние библиотеки, но мы можем их добавить позже
не нужен runtime, в дальнейшем мы сделаем свой
Это заготовка расширяемой системы, чтобы дв��гаться к своему рантайму нужно:
больше инструкций и форм адресации,
больше директив,
более удобный синтаксис,
возможно, простейший парсер текстового DSL,
затем - несколько сегментов, RW-данные, .bss, и т.д.
но все это уже становится специфичным для задачи.
Полный код уменьшается всего в пару сотен строк:
#!/bin/sh # sh emit.sh [output] # Mini 2-pass "assembler DSL" in pure POSIX sh (+ builtin printf). # Emits ELF64 x86_64 Linux: calls strlen-like routine, writes string, exits(42). # # If chmod is unavailable, write into an already-executable (+x) file. set -u P=0 # current file offset T=1 # pass: 1 = layout only, 2 = emit bytes SEC=text # logical section tag (text/data), informational fail() { printf 'error: %s\n' "$*" >&2; exit 1; } open_out() { exec 3>"$1" || fail "cannot open '$1' for writing"; } close_out() { exec 3>&-; } # ---------- low-level emit ---------- # emit one byte (low 8 bits) b() { n=$(( ($1) & 255 )) if [ "$T" = 2 ]; then o=$(printf '%03o' "$n") || fail "octal conversion" printf '%b' "\\$o" >&3 || fail "write" fi P=$((P + 1)) } w2() { v=$(( $1 )); b $v; b $((v >> 8)); } w4() { v=$(( $1 )); b $v; b $((v >> 8)); b $((v >> 16)); b $((v >> 24)); } w8() { v=$(( $1 )); b $v; b $((v >> 8)); b $((v >> 16)); b $((v >> 24)); b $((v >> 32)); b $((v >> 40)); b $((v >> 48)); b $((v >> 56)); } # emit bytes from hex string (e.g. h 7f454c46) h() { s=$1 while [ -n "$s" ]; do p=${s%"${s#??}"} # first two hex chars s=${s#??} b $((0x$p)) done } # ---------- labels / fixups ---------- L() { eval "L_$1=$P"; } # define label -> current file offset # get label value into global GV getL() { name=$1 eval "GX=\${L_$name+x}; GV=\${L_$name-0}" [ "${GX-}" = x ] || fail "unknown label: $name" } # abs64(label + addend), little-endian a8() { name=$1 add=${2:-0} if [ "$T" = 1 ]; then P=$((P + 8)) return fi getL "$name" w8 $((GV + add)) } # rel32(label + addend - next_ip), little-endian signed r4() { name=$1 add=${2:-0} if [ "$T" = 1 ]; then P=$((P + 4)) return fi getL "$name" d=$(( (GV + add) - (P + 4) )) w4 "$d" } # ---------- data directives ---------- db() { for x in "$@"; do b "$x"; done; } dw() { for x in "$@"; do w2 "$x"; done; } dd() { for x in "$@"; do w4 "$x"; done; } dq() { for x in "$@"; do w8 "$x"; done; } # dq absolute address of label (+ optional addend) dqA() { name=$1 add=${2:-0} a8 "$name" "$add" } # NUL-terminated string strz() { s=$1 if [ "$T" = 2 ]; then printf '%s' "$s" >&3 || fail "write string" printf '%b' '\000' >&3 || fail "write nul" fi P=$((P + ${#s} + 1)) } # raw string (without NUL) str() { s=$1 if [ "$T" = 2 ]; then printf '%s' "$s" >&3 || fail "write string" fi P=$((P + ${#s})) } align() { a=$(( $1 )) [ "$a" -gt 0 ] || fail "align: bad alignment" while [ $((P % a)) -ne 0 ]; do b 0; done } # ---------- logical sections (same PT_LOAD, just organization) ---------- text() { SEC=text; L __text; } data() { SEC=data; L __data; } # ---------- x86_64 instruction macros ---------- # lea rsi, [rip+label] i_lea_rsi() { h 488d35; r4 "$1"; } # xor edx, edx i_xor_edx_edx() { h 31d2; } # cmp byte [rsi+rdx], 0 i_cmpb_rsi_rdx_0() { h 803c1600; } # inc edx i_inc_edx() { h ffc2; } # mov eax, imm32 i_mov_eax() { h b8; w4 "$1"; } # mov edi, imm32 i_mov_edi() { h bf; w4 "$1"; } # syscall i_syscall() { h 0f05; } # ret i_ret() { h c3; } # call/jmp/jcc rel32 i_call() { h e8; r4 "$1"; } i_jmp() { h e9; r4 "$1"; } i_je() { h 0f84; r4 "$1"; } i_jne() { h 0f85; r4 "$1"; } # convenience for labels in code label() { L "$1"; } # ---------- program (uses the DSL) ---------- prog() { B=$((0x400000)) # image base virtual address (ET_EXEC) # --- ELF64 header (64 bytes) --- h 7f454c46 # ELF magic h 0201010000 # class=64, data=LE, version=1, osabi=sysv, abiver=0 h 00000000000000 # EI_PAD[7] w2 2 # e_type = ET_EXEC w2 0x3e # e_machine = EM_X86_64 w4 1 # e_version = EV_CURRENT a8 code "$B" # e_entry = B + code w8 64 # e_phoff w8 0 # e_shoff w4 0 # e_flags w2 64 # e_ehsize w2 56 # e_phentsize w2 1 # e_phnum w2 0; w2 0; w2 0 # no section table # --- Program header (PT_LOAD) --- w4 1 # p_type = PT_LOAD w4 5 # p_flags = PF_R | PF_X w8 0 # p_offset w8 "$B" # p_vaddr w8 "$B" # p_paddr a8 end # p_filesz = file size a8 end # p_memsz = file size w8 $((0x1000)) # p_align # --- .text (logical) --- text label code label _start # rsi = &msg i_lea_rsi msg # rdx = strlen(msg) via call i_call strlen0 # write(1, rsi, rdx) i_mov_eax 1 # SYS_write i_mov_edi 1 # fd=stdout i_syscall # exit(42) i_mov_eax 60 # SYS_exit i_mov_edi 42 i_syscall # strlen0: input rsi=ptr, output rdx=len label strlen0 i_xor_edx_edx label len_loop i_cmpb_rsi_rdx_0 i_je len_done i_inc_edx i_jmp len_loop label len_done i_ret # --- .data (logical) --- data align 8 label msg strz 'Hello from sh mini-asm DSL!' # purely for demo directives/fixups: label demo_numbers db 0x11 0x22 0x33 dw 0x4455 dd 0x66778899 dq 0x0102030405060708 # Абсолютный виртуальный адрес msg (abs64 fixup в данных) label ptr_to_msg dqA msg "$B" label end } # ---------- main ---------- main() { out=${1:-./a.out} # pass 1: layout + labels only T=1; P=0; prog # pass 2: actual emit T=2; P=0; open_out "$out"; prog; close_out printf 'Wrote %s bytes to %s\n' "$P" "$out" >&2 } main "$@"
Имея это, можно почувствовать в себе силы написать что-нибудь более масштабное.
