Один бинарник на четыре системы
Путем долгих экспериментов у меня все-таки получилось такое реализовать. Рассказываю как.
Справка для непричастных
Обычные унылые программы либо компилируются под какую-то конкретную ОС и архитектуру процессора, либо же требуют специальное окружение для запуска и работы. Которое еще нужно скачать и установить.
Это — норма и для коммерческого софта лучше так и делать впредь.
Хоть это и серо, скучно и уныло.
То что я покажу ниже в этой статье — «Proof of Concept» (PoC), доказательство что сделать единый запускаемый бинарник под четыре абсолютно разные и несовместимые платформы технически возможно.
Как это выглядит
Один исполняемый файл all.cmd без изменений, без перекомпиляции и сборок запускается «as-is» на Windows, Linux, FreeBSD и MacOS.
Запускается именно в пользовательском смысле:
по клику, по нажатию Enter, через ./all.cmd — так как запускается обычная родная программа в каждой конкретной ОС.
Без дополнительных действий, без каких-то параметров запуска, без всего.
Ниже несколько скриншотов из разных систем.
Как это работает
Шелл-скрипт упаковывается вместе с бинарником в один файл. Скрипт — в начале файла, бинарник с приложением — в конце.
Сама по себе технология известная. но у меня получилось развить ее несколько дальше:
Я использовал приложение на 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%
- Глаза вам не врут: curl и tar теперь действительно есть в Windows. На самом деле аж с 2017 года.
- Используем одну универсальную 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" для одного бинарника я еще не видел.
Поэтому сие является моим уникальным контентом, хоть и развитием старых идей.
Практическое применение такой штуки очень даже возможно и имеет смысл, поскольку исчезает необходимость генерации нескольких разных сборок под разные ОС.
Но естественно что нужно будет больше работы по оптимизации стартовых скриптов.