Представьте машину, на которой хочется собрать что-то исполняемое - но почти ничего нельзя.

Нет сети. Нет 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.

Поэтому на первом проходе просто считаем размеры, двигаем счётчик позиции, запоминаем смещения меток, а пишем уже на втором - получается мини-ассемблер "на коленке".

Заводим:

  1. Счётчик позиции P - Это текущий offset в файле. Любая директива или инструкция увеличивает P на свой размер. В первом проходе мы только считаем P, во втором - считаем и пишем.

  2. Метки как переменные 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, линкер, дебаггер).

Фиксапы нужны в нескольких местах сразу.

  1. e_entry. Точка входа - базовый виртуальный адрес (0x400000) плюс смещение метки code

  2. p_filesz и p_memsz. Размер сегмента - это размер всего файла до метки end.

  3. call/jmp/je. Переходы rel32 кодируются как: target - next_ipА next_ip - это позиция после поля смещения, которую мы узнаём только после первого прохода.

  4. 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 "$@"

Имея это, можно почувствовать в себе силы написать что-нибудь более масштабное.