software-development
April 2, 2023

Один бинарник на четыре системы

Путем долгих экспериментов у меня все-таки получилось такое реализовать. Рассказываю как.

Музыка для поста:

Справка для непричастных

Обычные унылые программы либо компилируются под какую-то конкретную ОС и архитектуру процессора, либо же требуют специальное окружение для запуска и работы. Которое еще нужно скачать и установить.

Это — норма и для коммерческого софта лучше так и делать впредь.

Хоть это и серо, скучно и уныло.

То что я покажу ниже в этой статье — «Proof of Concept» (PoC), доказательство что сделать единый запускаемый бинарник под четыре абсолютно разные и несовместимые платформы технически возможно.

Демонстрационное видео с одним бинарником для MacOS и Windows 10

Как это выглядит

Один исполняемый файл all.cmd без изменений, без перекомпиляции и сборок запускается «as-is» на Windows, Linux, FreeBSD и MacOS.

Запускается именно в пользовательском смысле:

по клику, по нажатию Enter, через ./all.cmd — так как запускается обычная родная программа в каждой конкретной ОС.

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

Вообщем, по хардкору.

Ниже несколько скриншотов из разных систем.

Linux Manjaro:

FreeBSD 13:

Windows 10:

Windows 11:

MacOS Catalina (в эмуляторе):

Как это работает

Шелл-скрипт упаковывается вместе с бинарником в один файл. Скрипт — в начале файла, бинарник с приложением — в конце.

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

Я использовал приложение на Java в качестве бинарника, но тоже самое можно реализовать и с дотнетом и с Python-приложением.

Jar файл, в который упаковывается Java-приложение является ZIP-архивом, особенностью формата которого является то, что процесс чтения начинается с конца файла.

А вот шелл-скрипт как и Windows Batch выполняется пошагово с начала файла. Это и позволяет сделать запуск «себя самого»:

self=`(readlink -f $0)`
java -jar $self 
exit

Проблема Windows

Microsoft как известно всегда идет своим путем, который затем навязывает окружающим. И их командный интерпретатор не стал исключением.

Не думал, что вообще возможно сделать чтобы один и тот же скрипт выполнился как в Windows Batch так и в обычном bash.

Но как ни странно решение нашлось.

Скелет скрипта, который отрабатывает и в Windows и в bash вот:

rem(){ :;};rem '
@goto b
';echo Starting Demo..;
:b
@echo off
@echo Starting Demo...

Работает это за счет пересечения синтаксиса из двух миров:

для Windows Batch rem это функция пропуска строки, те все что начинается со слова rem полностью пропускается вендовым интерпретатором.

А вот для bash rem() это определение пустой функции и ее немедленный вызов с мультистрокой:

rem '
@goto b
';

Те фактически bash этот блок пропустит.

А вот вендовый cmd.exe сделает переход по метке:

@goto b

В блок, где начинается уже полноценный код для Windows Batch:

:b
@echo off
@echo Starting Demo...

Вот таким чудным образом получается единая точка запуска для всего.

И никакой магии.

Определение окружения

Чтобы запустить приложение на Java нужен рантайм — JRE, которого на машине может и не быть, либо он может быть устаревшей версии.

Поэтому для полной радости, я добавил проверку версии и автоматическое скачивание JRE для Windows и Linux платформ.

Для Linux учитывается тип архитектуры.

Общая логика для Linux, FreeBSD и MacOS выглядит вот так:

echo "1. Searching for Java JRE.."
if type -p java; then
    echo "1.1 Found Java executable in PATH"
    _JRE=java
elif [[ -n $JAVA_HOME ]] && [[ -x "$JAVA_HOME/bin/java" ]];  then
    echo "1.2 Found Java executable in JAVA_HOME"
    _JRE="$JAVA_HOME/bin/java"
else
    echo "1.3 no JRE found"    
fi

v="$(jdk_version)"
echo "2. Detected Java version: $v"
if [[ $v -lt 8 ]]
then
    echo "2.1 Found unsupported version: $v"
    try_download_java
    echo "2.2 Using JRE: $_JRE"
fi
self=`(readlink -f $0)`
$_JRE -jar $self
exit

Сначала мы ищем «java» в виде команды, доступной из окружения.

Если не нашли — проверяем наличие переменной JAVA_HOME в окружении (в этой переменной указывается обычно путь до JDK)

Дальше проверяем версию найденной Java:

# returns the JDK version.
# 8 for 1.8.0_nn, 9 for 9-ea etc, and "no_java" for undetected
jdk_version() {
  local result
  local java_cmd
  if [[ -n $(type -p java) ]]
  then
    java_cmd=java
  elif [[ (-n "$JAVA_HOME") && (-x "$JAVA_HOME/bin/java") ]]
  then
    java_cmd="$JAVA_HOME/bin/java"
  fi
  local IFS=#x27;\n'
  # remove \r for Cygwin
  local lines=$("$java_cmd" -Xms32M -Xmx32M -version 2>&1 | tr '\r' '\n')
  if [[ -z $java_cmd ]]
  then
    result=no_java
  else
    for line in $lines; do
      if [[ (-z $result) && ($line = *"version \""*) ]]
      then
        local ver=$(echo $line | sed -e 's/.*version "\(.*\)"\(.*\)/\1/; 1q')
        # on macOS, sed doesn't support '?'
        if [[ $ver = "1."* ]]
        then
          result=$(echo $ver | sed -e 's/1\.\([0-9]*\)\(.*\)/\1/; 1q')
        else
          result=$(echo $ver | sed -e 's/\([0-9]*\)\(.*\)/\1/; 1q')
        fi
      fi
    done
  fi
  echo "$result"
}

Суть в том чтобы получить одно число, соответствующее мажорной версии найденной JRE: 8 — для Java 1.8, 9 — для Java 9, 11,14,19 и так далее.

Эта функция вызывается, затем проверяется полученное число версии:

v="$(jdk_version)"
echo "2. Detected Java version: $v"
if [[ $v -lt 8 ]]
then
    echo "2.1 Found unsupported version: $v"
    try_download_java
    echo "2.2 Using JRE: $_JRE"
fi

Если найденная JRE слишком старая - пытаемся скачать.

Но сначала проверяем есть ли уже скачанная версия:

UNPACKED_JRE=~/.jre/jre
if [[ -f "$UNPACKED_JRE/bin/java" ]]; then
    echo "3.1 Found unpacked JRE"
    _JRE="$UNPACKED_JRE/bin/java"
    return 0
fi

Вот так выглядит определение типа архитектуры и сопоставление части имени файла со скачиваемым JRE:

# Detect the platform (similar to $OSTYPE)
OS="`uname`"
ARCH="`uname -m`"
# select correct path segments based on CPU architecture and OS
case $ARCH in
   'x86_64')
     ARCH='x64'
     ;;
    'i686')
     ARCH='i586'
     ;;
    *)
    exit_error "Unsupported for automatic download"
     ;;
esac

Обратите внимание что 32битная система с линуксом будет называть себя i686, а в названии 32битной JRE будет i586.

К сожалению бинарных сборок в виде скачиваемого архива для FreeBSD и MacOS нет, поэтому пока вот так:

case $OS in
  'Linux')
    OS='linux'
    ;;
  *)
    exit_error "Unsupported for automatic download"
     ;;
esac

Эти параметры затем подставляются в полную ссылку для скачивания:

echo "3.2 Downloading for OS: $OS and arch: $ARCH"
URL="https://../../jvm/com/oracle/jre/1.8.121/jre-1.8.121-$OS-$ARCH.zip"
echo "Full url: $URL"

Откуда берется JRE

Вообще Oracle не дает скачивать релизы JRE в автоматическом режиме, поэтому для тестов выложили бинарные сборки OpenJDK и JRE в виде зависимостей Maven, вот тут.

Это устаревшая 1.8 версия, но для PoC прототипа хватит.

Ниже опишу логику работы скрипта.

Вот так происходит скачивание и распаковка:

echo "Full url: $URL"
CODE=$(curl -L -w '%{http_code}' -o /tmp/jre.zip -C - $URL)
if [[ "$CODE" =~ ^2 ]]; then
    # Server returned 2xx response
    mkdir -p ~/.jre
    unzip /tmp/jre.zip -d ~/.jre/
    _JRE="$UNPACKED_JRE/bin/java"
    return 0
elif [[ "$CODE" = 404 ]]; then
    exit_error "3.3 Unable to download JRE from $URL"
else
    exit_error "3.4 ERROR: server returned HTTP code $CODE"
fi

По идее нужно еще отдельно проверять возвращаемые коды при создании каталога и распаковке — но для PoC думаю и текущей логики хватит.

Часть с Windows

Теперь детально разберем часть скрипта, отвечающего за венду. Вот отсюда она начинается:

:b
@echo off
@echo Starting Demo...
:: self script name
set SELF_SCRIPT=%0

Первым делом сохраняем полный путь до себя самого (%0) в переменную SELF_SCRIPT, тк дальше он может быть перезаписан.

Дальше определяем путь до распакованной JRE, которая будет храниться в домашней папке текущего пользователя:

:: path to unpacked JRE
set UNPACKED_JRE_DIR=%UserProfile%\.jre
:: path to unpacked JRE binary
set UNPACKED_JRE=%UNPACKED_JRE_DIR%\jre\bin\javaw.exe

IF exist %UNPACKED_JRE% (goto :RunJavaUnpacked)

Если бинарник JRE существует — считаем что JRE уже был скачан и используем его.

Обратите внимание на особенность вендов, в виде отдельного бинарника для графических приложений: javaw.exe

Если скачанного JRE нет — пытаемся найти в окружении. Если нашли — пытаемся определить версию:

where javaw 2>NUL
if "%ERRORLEVEL%"=="0" (call :JavaFound) else (call :NoJava)
goto :EOF
:JavaFound
set JRE=javaw
echo Java found in PATH, checking version..
set JAVA_VERSION=0
for /f "tokens=3" %%g in ('java -version 2^>^&1 ^| findstr /i "version"') do (
  set JAVA_VERSION=%%g
)
set JAVA_VERSION=%JAVA_VERSION:"=%
for /f "delims=.-_ tokens=1-2" %%v in ("%JAVA_VERSION%") do (
  if /I "%%v" EQU "1" (
    set JAVA_VERSION=%%w
  ) else (
    set JAVA_VERSION=%%v
  )
)

Считаем, что если есть javaw.exe то есть и java.exe, поскольку в вендовых сборках оба бинарника обязательно присутствуют в папке bin.

Общий смысл кода выше ровно такой же что и для bash версии - получить мажорную цифру версии JRE для последующей проверки:

if %JAVA_VERSION% LSS 8 (goto :DownloadJava) else (goto :RunJava)

Если найденная JRE старше 1.8 — считаем что она не поддерживается и пытаемся скачать.

Вот так выглядит скачивание и распаковка JRE:

:DownloadJava
echo JRE not found in PATH, trying to download..
WHERE curl
IF %ERRORLEVEL% NEQ 0 (call :ExitError "curl wasn't found in PATH, cannot download JRE") 
WHERE tar
IF %ERRORLEVEL% NEQ 0 (call :ExitError "tar wasn't found in PATH, cannot download JRE")  
curl.exe -o %TEMP%\jre.zip  -C - https://nexus.nuiton.org/nexus/content/repositories/jvm/com/oracle/jre/1.8.121/jre-1.8.121-windows-i586.zip
IF not exist %UNPACKED_JRE_DIR% (mkdir %UNPACKED_JRE_DIR%)
tar -xf %TEMP%\jre.zip -C %UNPACKED_JRE_DIR%

Важные моменты:

  1. Глаза вам не врут: curl и tar теперь действительно есть в Windows. На самом деле аж с 2017 года.
  2. Используем одну универсальную 32х битную версию JRE, без учета архитектуры, поскольку на Windows нет проблемы совместимости и запуска 32х битных приложений на x86_64 архитектуре. Ну и это все же PoC для тестирования а не жирный продакшн.

Код запуска выглядит вот так:

:RunJavaUnpacked
set JRE=%UNPACKED_JRE_DIR%\jre\bin\javaw.exe
:RunJava
echo Using JRE %JAVA_VERSION% from %JRE%
start %JRE% -jar %SELF_SCRIPT%
goto :EOF
:ExitError
echo Found Error: %0
pause
:EOF
exit

Тут необходимо пояснить про ссылочную логику и метки. Команда goto делает переход в место скрипта отмеченное меткой:

goto :EOF

перейдет вот сюда, минуя весь остальной код:

:EOF
exit

А если метки нет то выполнение продолжится последовательно, поэтому после:

set JRE=%UNPACKED_JRE_DIR%\jre\bin\javaw.exe

выполнится:

echo Using JRE %JAVA_VERSION% from %JRE%

И дальше до конца.

MacOS и readlink

Оказалось что реализация readlink на маке не поддерживает ключ -f , несмотря на все визги про то что MacOS это тоже BSD.

Поэтому пришлось добавлять реализацию прямо в скрипт:

# Return the canonicalized path (works on OS-X like 'readlink -f' on Linux); . is $PWD
function get_realpath {
    [ "." = "${1}" ] && n=${PWD} || n=${1}; while nn=$( readlink -n "$n" ); do n=$nn; done; echo "$n"
}

Эта функция используется для вычисления собственного полного пути, с учетом ссылок и относительных частей.

Шелл по-умолчанию

В MacOS начиная с Catalina по-умолчанию используется zsh, в FreeBSD - ksh а в большинстве линуксов - bash.

Код загрузчика для юниксов в этом проекте написан для bash. Чтобы автоматически перезапустить скрипт через bash, если пользователь запускает другим интерпретатором, используется вот такой код:

if [ -z "$BASH" ]; then 
echo "0. Current shell is not bash. Trying to re-run with bash.." 
exec bash $0
exit
fi

Тестовый проект

Весь проект выложен на Github вот тут.

Имейте ввиду что две части шелл-скрипта - для Windows Batch и для bash имеют разную настройку окончания строк!

Это оказалось обязательным для запуска на MacOS.

Само тестовое приложение на Swing, примечательно циклом сборки.

Я использовал BeanShell plugin, для того чтобы реализовать логику упаковки в виде inline-скрипта на самой Java:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.Ox08.experiments</groupId>
    <artifactId>full-cross</artifactId>
    <version>1.0-RELEASE</version>
    <name>0x08 Experiments: Full Cross Application</name>
    <packaging>jar</packaging>
    <url>https://teletype.in/@alex0x08</url>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <exec.mainClass>com.ox08.demos.fullcross.FullCross</exec.mainClass>
    </properties>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.3.0</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>com.ox08.demos.fullcross.FullCross</mainClass>
                        </manifest>                       
                    </archive>
                </configuration>
            </plugin>
            <plugin>
                <groupId>com.github.genthaler</groupId>
                <artifactId>beanshell-maven-plugin</artifactId>
                <version>1.4</version>                
                <executions>
               <execution>
                  <phase>package</phase>
                  <goals>
                     <goal>run</goal>
                  </goals>
               </execution>
            </executions>
                <configuration>
                    <quiet>true</quiet>                
                    <script>
                <![CDATA[
                        import java.io.*; 
                        // function should be defined before actual call
                        // this just appends source binary to target
                         void copy(File src,OutputStream fout) {
                            FileInputStream fin = null;
                            try {    
                            fin =new FileInputStream(src);                         
                            byte[] b = new byte[1024];
                            int noOfBytes = 0; 
                            while( (noOfBytes = fin.read(b)) != -1 )
                            { fout.write(b, 0, noOfBytes);  } 
                            } catch (Exception e) {
                                e.printStackTrace();
                            } finally {
                                fout.flush();
                                if (fin!=null) { fin.close(); }
                            }                             
                        }                  
                        // current project folder                                           
                        String projectDir = System.getProperty("maven.multiModuleProjectDirectory");
                        // target combined binary
                        File target = new File(projectDir+"/target/all.cmd");    
                        if (target.exists()) {
                            target.delete();
                        }            
                        // shell bootloader
                        File fboot = new File(projectDir+"/src/main/cmd/boot.cmd");                
                        // jar file with application    
                        File fjar = new File(projectDir+"/target/full-cross-1.0-RELEASE.jar");                
                        // open write stream to target combined binary
                        FileOutputStream fout = new FileOutputStream(target);
                        // write bootloader
                        copy(fboot,fout);
                        // write jar
                        copy(fjar,fout);
                        fout.close();
                        target.setExecutable(true);
                ]]>
                    </script>
                </configuration>                
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-install-plugin</artifactId>
                <version>3.1.1</version>
                <configuration>
                    <skip>true</skip>
                </configuration>
            </plugin>
          </plugins>
    </build>
</project>

Собирается абсолютно любой JDK cтарше 1.8 версии:

mvn clean package

Можно использовать внешний Apache Maven, либо собрать из любой среды разработки.

Итоговый бинарник будет в папке target.

Нехорошие дела

Полагаю у некоторых читателей далеких от ИБ, появилась мысль о возможности нехорошего применения этой технологии: сваять какую-нибудь кроссплатформенную малварь/шифровальщик/вирь и все в этом духе.

На самом деле так заморачиваться никому не интересно - объем кода, который делает эскалацию привилегий в системе, открывает бекдор или творит еще какую дичь и так немаленький. Тащить три разных версии такого кода, под разные ОС, с собой в одном бинарнике - ну совсем перебор.

Второй важный момент - командный интерпретатор выполняется пошагово, там нет тредов, поэтому весь код отлично просматривается любыми антивирусами, даже самыми тупыми.

Вообщем не интересно это нормальным бандитам.

Выводы

В принципе Америку я не открыл - такое известно давно и давно используется на практике. Вот тут находится огромная статья с примерами на разных языках.

Вот тут находится проект для создания кросс-платформенных самораспаковывающихся архивов под разные типы Unix, используется та же идея.

Тем не менее, полную сборку в готовое решение, с кроссплатформенностью "Windows-Mac-Linux-BSD" для одного бинарника я еще не видел.

Поэтому сие является моим уникальным контентом, хоть и развитием старых идей.

Практическое применение такой штуки очень даже возможно и имеет смысл, поскольку исчезает необходимость генерации нескольких разных сборок под разные ОС.

Но естественно что нужно будет больше работы по оптимизации стартовых скриптов.

P.S: Иконку на такой бинарь к сожалению не поставить, увы.