Представьте машину, на которой хочется собрать что-то исполняемое - но почти ничего нельзя.
Нет сети. Нет 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) плюс смещение метки code -
p_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: %sn' "$*" >&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' '00' >&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 %sn' "$P" "$out" >&2
}
main "$@"
Имея это, можно почувствовать в себе силы написать что-нибудь более масштабное.
Автор: ivandenisoff
