LINUX.ORG.RU

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

 , , , ,


8

1

Это текстовая версия статьи, оригинал с картинками вот тут

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

This is Spartaaa!

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

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

То что я покажу ниже в этой статье — «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» для одного бинарника я еще не видел.

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

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

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

★★★

Проверено: hobbit ()
Последнее исправление: hobbit (всего исправлений: 8)

Ответ на: комментарий от xDShot

Идея не новая

Конечно не новая, на основе таких вот примеров я и делал.

З.Ы. Чувак, ты потратил чудесное субботнее летнее утро на то чтобы поставить гору дизлайков и написать гневный коммент? Серьезно?

alex0x08 ★★★
() автор топика
Ответ на: комментарий от alex0x08

Сейчас программирование и то изучают по ютубу )

Это вчера. Сегодня программирование «изучают», описав на кривом английско/русском, что хотят, и надеясь, что ChatGPT угадает, что они хотят на самом деле и напишет код.

CrX ★★★★★
()

А так идея занятная, но заголовок статьи надо поправить, ибо сейчас там 4.2.

CrX ★★★★★
()
Ответ на: комментарий от alex0x08

Как минимум, убрать слово «бинарник», потому что там в основе скрипт. Заменить на «запускаемый файл» или «блоб», и будет уже хотя бы соответствовать истине. Но станет не так красиво — для красоты можно вообще переименовать как-то ещё (как именно у меня предложений нет).

CrX ★★★★★
()
Ответ на: комментарий от CrX

Блоб вообще-то бинарник и есть, тот же firmware blob - вполне себе запускаемая программа, просто запускается изнутри. Запускаемый файл? - ну тоже не очевидно, поскольку есть бит запуска, без которого даже официальный бинарь не запустится напрямую. Причем в венде таким битом запуска является расширение, поэтому и .cmd у скрипта. Плюс shebang, при наличии которого можно было написать весь скрипт на Java:

#!/usr/lib/jvm/openjdk-14.0.1/bin/java --source 11

import java.util.Locale;

class Hello {
    public static void main(String[] args) {
        String lang = Locale.getDefault().getLanguage();
        System.out.println(Greetings.getGreeting(lang));
    }
}

class Greetings {
    static String getGreeting(String lang) {
        switch (lang) {
            case "fr": return "Bonjour";
            case "es": return "Hola";
            case "zh": return "Nǐn hǎo";
            case "de": return "Guten Tag";
            case "pl": return "Dzień dobry";
            case "el": return "Yassas";
            case "sv": return "God dag";
            default: return "Hi";
        }
    }
}

Так что не знаю, куда не глянь - везде жопа с глазами.

alex0x08 ★★★
() автор топика
Ответ на: комментарий от alex0x08

Видимо все-таки нет понимания.
Чтобы обслуживать тупых и предсказуемых нужно предсказуемое, унифицированное окружение. На сейчас таких три: мак, венды и убунту.

Ну можно еще что-то средне-универсальное сделать под популярные линуксы но и только. Для всех остальных (в первую очередь BSD) нужна инструкция и описание - самим пользователям так проще, они привыкли разбираться сами.

Кстати сам факт запуска их софта на BSD часто вендора вгоняет в ступор - они просто не готовы к тому что пользователь может быть умнее и технически более грамотным чем его собственные разработчики.

На пятый день до тебя начинает доходить мой тонкий юмор из последнего абзаца этого комментария. ☺

mord0d ★★★★★
()
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.