четверг, 4 августа 2011 г.

Недокументированный CreateProcess

Это перевод Undocumented CreateProcess. Автор: James Brown.

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

Этот мануал является частью новой серии, которая будет сосредоточена на некоторых не-GUI вопросах, связанных с программированием в Windows. Предметом этого мануала будет Win32 API функция CreateProcess. Эта статья разделена на несколько секций, каждая из которых описывает приятный факт о CreateProcess, который можно использовать в своих интересах. То, что я буду описывать, нельзя найти в документации Microsoft, но эти вещи были обнаружены многими людьми на протяжении многих лет путём множества экспериментов. Вся информация, собранная здесь, была найдена в различных источниках - особенно в старых публикациях таких изданий, как "Windows Developer Journal", начиная с середины 90-х годов, а также старых сообщениях USENET.

Прежде чем я начну говорить про недокументированные штуки, я хотел бы кратко рассказать про то, что делает CreateProcess, и как её использовать в вашем коде. Если вы знакомы с CreateProcess, то просто пропустите эту секцию
function CreateProcess(
    lpApplicationName: PChar;                      // имя исполняемого модуля для запуска
    lpCommandLine: PChar;                          // командная строка
    lpProcessAttributes: PSecurityAttributes;      // SD
    lpThreadAttributes: PSecurityAttributes;       // SD
    fInheritHandles: BOOL;                         // опции наследования описателей
    dwCreationFlags: DWORD;,                       // флаги создания
    lpEnvironment: Pointer;,                       // новый блок переменных окружения
    lpCurrentDirectory: PChar;                     // имя текущей папки
    var lpStartupInfo: TStartupInfo;               // стартовая информация
    var lpProcessInformation: TProcessInformation; // информация процесса
): BOOL; stdcall;
Функция может оказаться немного сложна для понимания на первый взгляд. К счастью, большинство параметров в CreateProcess можно опустить и стандартный способ создания нового процесса выглядит следующим образом:
var
  SI: TStartupInfo;
  PI: TProcessInformation;
  Exe: String;
begin
  FillChar(SI, SizeOf(SI), 0);
  SI.cb := SizeOf(SI);

  Exe := 'cmd.exe';
  UniqueString(Exe);

  if CreateProcess(nil, PChar(Exe), 0, 0, False, 0, 0, 0, SI, PI) then
  begin
    // Опционально: ждём завершения процесса
    WaitForSingleObject(pi.hProcess, INFINITE);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
  end;
Пример выше просто запускает новый экземпляр cmd.exe. Однако у функции есть множество опций, позволяющих контролировать то, как запускается программа. Некоторые из этих настроек указываются непосредственно в параметрах функции, но большинство из них передаются в записи TStartupInfo:
type
  TStartupInfo = record
    cb: DWORD;
    lpReserved: PChar;
    lpDesktop: PChar;
    lpTitle: PChar;
    dwX: DWORD;
    dwY: DWORD;
    dwXSize: DWORD;
    dwYSize: DWORD;
    dwXCountChars: DWORD;
    dwYCountChars: DWORD;
    dwFillAttribute: DWORD;
    dwFlags: DWORD;
    wShowWindow: Word;
    cbReserved2: Word;
    lpReserved2: PByte;
    hStdInput: THandle;
    hStdOutput: THandle;
    hStdError: THandle;
  end;
Запись TStartupInfo также документирована в MSDN, но часть интересной информации не указана - её-то мы сейчас и будем изучать. Вся статья будет в основном вращаться вокруг записи TStartupInfo и её недокументированных полей: lpReserved, lpReserved2 и cbReserved2.

Чуть позже я объясню, для чего на самом деле используются эти зарезервированные поля, но сначала давайте посмотрим на поле dwFlags и то, что он делает. Тут будет подходящим заглянуть в MSDN:
STARTF_USESHOWWINDOW = $01
Если это значение не указано, то поле wShowWindow игнорируется.

STARTF_USESIZE = $02
Если это значение не указано, то поля dwXSize и dwYSize игнорируются.

STARTF_USEPOSITION = $04
Если это значение не указано, то поля dwX и dwY игнорируются.

STARTF_USECOUNTCHARS = $08
Если это значение не указано, то поля dwXCountChars и dwYCountChars игнорируются.

STARTF_USEFILLATTRIBUTE = $10
Если это значение не указано, то поле dwFillAttribute игнорируется.

STARTF_RUNFULLSCREEN = $20
Указывает, что процесс следует запускать в полноэкранном режиме. В противном случае - в оконном.

STARTF_FORCEONFEEDBACK = $40
Указывает, что курсор сохраняет форму песочных часов до двух секунд после вызова CreateProcess.

STARTF_FORCEOFFFEEDBACK = $80
Указывает, что курсор не меняет форму во время запуска процесса. Используется обычный курсор.

STARTF_USESTDHANDLES = $100
Устанавливает описатели стандартного ввода, вывода и канала ошибок на указанные в полях hStdInput, hStdOutput и hStdError записи TStartupInfo.
Эти девять значений (или битовых флагов) могут быть указаны по отдельности или вместе - через OR. Таблица выше довольно скучна, потому что кроме флага START_USESTDHANDLES там нет ничего особо интересного. Однако, посмотрев на диапазон значений (от $01 до $100), мы увидим, что используется только 9 флагов (битов) из 32 возможных - что оставляет 23 флага, которые ещё не определены.

Для начала нам хватит вводной информации, давайте посмотрим на что-то более интересное.

Определяем, запущены ли мы через ярлык (shortcut)
OK, первый трюк, который я вам покажу - это определение, запущены ли мы через ярлык (т.е. двойным щелчком по .lnk файлу) или напрямую - через Проводник Windows, диалог Run или программно. Это настолько просто, что мне удивительно, почему это не документировано.

Существует недокументированный флаг, который я назову STARTF_TITLEISLINKNAME (прим.пер.: сейчас флаг документирован, имя изменено. В оригинале - STARTF_TITLESHORTCUT). Этот битовый флаг имеет числовое значение $800. Windows устанавливает его, когда приложение запускается через ярлык. Так что любая программа может узнать, как её запустили - анализом своей собственной записи TStartupInfo:
// Возвращает True, если нас запустили через ярлык; False - в противном случае
// Также возвращает имя файла ярлыка (если доступно)
function GetShortcutName(out ALinkName: String): Boolean;
const
  STARTF_TITLEISLINKNAME = $800;
var
  SI: TStartupInfo;
begin
  FillChar(SI, SizeOf(SI), 0);
  SI.cb := SizeOf(SI); 
  GetStartupInfo(SI);

  if (si.dwFlags and STARTF_TITLEISLINKNAME) <> 0 then
  begin
    ALinkName := SI.lpTitle;

    Result := True;
  end
  else
    Result := False;
end;
В общем-то, тут всё сводится к проверке флага, но надо пояснить один момент: когда установлен флаг STARTF_TITLEISLINKNAME, поле lpTitle записи TStartupInfo указывает на строку, содержащую полный путь к файлу ярлыка, который использовался для запуска вашего приложения. Представьте, что на вашем рабочем столе есть ярлык, который запускает Блокнот (notepad.exe). Когда запускается notepad.exe, то его TStartupInfo.lpTitle содержит такой текст:
C:\Documents and Settings\James\Desktop\Notepad.lnk
Очень клёво, да? Ну, я надеюсь, что я подогрел ваш аппетит, так что мы двигаемся к следующей недокументированной возможности!

Указание, на каком мониторе нужно запускать процесс
Следующая недокументированная возможность - очередной флаг записи TStartupInfo. Флаг имеет значение $400 и я назвал его STARTF_MONITOR.

Когда в поле dwFlags указан флаг STARTF_MONITOR, поле hStdOutput записи TStartupInfo используется для указания описателя монитора, на котором нужно запускать новый процесс. Вы можете получить описатель монитора от любой функции перечисления экранов (прим.пер.: в Delphi - это свойство Handle у элементов массива Monitors объекта Screen из модуля Forms).

Тут есть определённые ограничения, о которых нужно сказать. Вы можете спросить, как это работает, если поле hStdOutput используется для описателя канала вывода. Ответ прост - когда указан недокументированный флаг STARTF_MONITOR, то флаг STARTF_USESTDHANDLES игнорируется. Это означает, что эти два флага нельзя использовать одновременно, а поля hStdInput, hStdOutput и hStdError трактуются разными способами, в зависимости от установленных флагов.

Следующее ограничение очевидно: когда вы запускаете новый процесс с помощью CreateProcess, тут нет никакой концепции мониторов, окон и прочих GUI-вещей. Оконная программа (если она оконная) должна сама явно вызвать CreateWindow для создания своих окон, своего GUI. Вот тут-то и выходит на сцену ограничение флага STARTF_MONITOR. Когда процесс вызывает CreateWindow, он может явно указать, где именно создавать окно - указанием числовых значений координат и размеров окна. Это означает, что одна программа не может указать другой, как ей создавать окна, если только сама программа это явно не позволит - указанием специальных параметров при создании окна (прим.пер.: указанием CW_USEDEFAULT и CW_USEDEFAULT для координат в случае WinAPI и poDefaultPosOnly или poDefault в свойстве в Position случае VCL).

Так что только когда сама программа позволяет системе указывать положение окна, используется монитор, указываемый в CreateProcess. К примеру, так работает игра Пасьянс. Но этот подход не сработает с Блокнотом - он, похоже, всегда явно указывает координаты окна.

Заметьте, что функция ShellExecuteEx использует эту возможность CreateProcess для реализации своих собственных опций монитора (см. флаг SEE_MASK_HMONITOR).

Запуск хранителя экрана (screensaver)
В старых версиях SDK Microsoft Windows был описан флаг, называемый STARTF_SCREENSAVER со значением $80000000. Сейчас этот флаг более не документирован. Когда задаётся этот флаг, то процесс запускается с приоритетом NORMAL_PRIORITY, но как только этот новый процесс делает первый вызов GetMessage, то его приоритет автоматически опускается до IDLE_PRIORITY. Эта функциональность может быть немного полезна для хранителей экрана и полностью бесполезна для большинства приложений. Кажется, это поведение было спроектировано для быстрого старта хранителя экрана и последующего "нормального" его выполнения, без заметного влияния на систему.

Кажется, что только процессу WinLogon (winlogon.exe) позволено использовать флаг STARTF_SCREENSAVER во время активации хранителя экрана, так что этот флаг бесполезен в других сценариях.

Устаревшая функциональность Диспетчера программ
Вы помните Диспетчер программ (Program Manager) из Windows 3.1? Он существует даже сегодня (в Windows XP): зайдите в командную строку или диалог "Выполнить" и наберите "progman" - запустится знакомая оболочка Диспетчера программ. Даже во времена Windows 3.1 (редакций home и NT) у CreateProcess существовала недокументированное поведение. Я собираюсь поделиться с вами этой информацией - даже хотя сегодня она практически бесполезна, но узнать о ней будет интересно.

Если вы посмотрите на определение TStartupInfo, то вы увидите поле lpReserved. Это поле вообще-то постоянно используется, но только Диспетчером программ, когда он запускает программы.

Это поле указывает на строковый буфер в таком формате:
dde.#,hotkey.#,ntvdm.#
Каждый раз, когда программа запускается Диспетчером программ, она вызывает GetStartupInfo, чтобы узнать о параметрах запуска. И в этом случае поле lpReserved будет содержать строку с тремя полями, разделёнными запятыми, с #, указывающими hex-значения:
  • Часть "dde." указывает идентификатор DDE, который дочерний процесс может использовать для общения с Диспетчером программ. Когда дочерний процесс отправляет progman-у сообщение WM_DDE_REQUEST с этим ИД, то progman отвечает сообщением WM_DDE_DATA. Это сообщение содержит, среди всего прочего, описание progman, его индекс иконки и рабочую папку для дочернего процесса.

    Более подробно об этом механизме можно почитать в статье Knowledge Base номер 105446.
  • Часть "hotkey." указывает на комбинацию hot-key Диспетчера программ, которая была использована для запуска программы. Это 16-битное hex число, в младшем байте которого находится ASCII код hot-key, а в старшем - комбинация значений HOTKEYF_xxx. Я понятия не имею, зачем дочернему процессу могла понадобится такая информация.
  • Часть "ntvdm." используется для информирования процесса NTVDM о свойствах программы в Диспетчере программ. Это простое hex-поле, которое представляет собой комбинацию битовых флагов. К примеру, значение 1 указывает, что задан текущий каталог, значение 2 - что у программы есть hot-key, значение 4 - что указан заголовок программы.
Вот и всё, что касается поля lpReserved. Сегодня это бесполезная возможность - даже хотя вы всё ещё можете использовать поле lpReserved в вызове CreateProcess, ни одна (современная) программа никогда не будет его читать. Вы можете посмотреть на действие этого поля, запуская ваше приложение из Диспетчера программ.

Конечно же, если у вас есть два ваших приложения, разработанных специально для взаимодействия друг с другом, и вы хотите передавать второму приложению данные (по какой-то причине) иным способом, нежели через командную строку, то поле lpReserved будет вам полезным.

Устаревшая функциональность ShellExecuteEx
Функция ShellExecuteEx появилась аж в Windows NT 3.1 (но не в "обычной" 3.1). Она принимает единственный параметр - указатель на запись TShellExecuteInfo. Вы можете посмотреть определение этой структуры в MSDN (довольно скучно!). Но в ней есть несколько полей, которые тоже не документированы.

Во-первых, это поле hMonitor. ShellExecuteEx использует это поле для контроля монитора, на котором следует появиться запускаемому процессу. Для реализации этой возможности ShellExecuteEx вызывает CreateProcess, указывая обсуждаемый выше флаг STARTF_MONITOR.

Следующее интересное поле - это поле hIcon. В нём указывается описатель иконки открываемого файла, если поле флагов содержит флаг SEE_MASK_ICON со значением $10. Мне не удалось найти информацию по применению этой возможности - всё, что я могу сказать, так это то, что эту возможность использовали консольные приложения Windows NT 3.1/3.5 (новые программы всегда игнорируют иконку). Для реализации этой возможности ShellExecuteEx использует недокументированный флаг в CreateProcess, который я назову STARTF_ICON. Странно, но этот флаг численно равен флагу STARTF_MONITOR: $400. Видимо, подразумевается, что иконка передаётся консольным программам, а монитор - визуальным.

Последнее интересное поле - это dwHotKey (используемого только при наличии флага SEE_MASK_HOTKEY). Оно предполагается для назначения hot-key дочернему процессу, так что вы можете активировать приложение в любое время нажатием этой комбинации. Однако мне не удалось заставить работать эту возможность. Может быть, она была удалена из системы. И снова, ShellExecuteEx использует недокументированный флаг CreateProcess - это флаг STARTF_USEHOTKEY со значением $200. Когда указывается этот флаг, поле hStdInput должно содержать значение hot-key вместо канала ввода (см. WM_SETHOTKEY).

И иконка и hot-key являются странными возможностями CreateProcess, во-первых потому, что эта функциональность, кажется дублируется параметром lpReserved и, во-вторых, она убрана из современных версий Windows. Если кто-то владеет другой информацией по этой теме - я буду счастлив её услышать!

Передача произвольных данных дочернему процессу
Последний недокументированный трюк несколько отличается от упомянутых выше, так что я решил оставить его под конец. Запись TStartupInfo содержит два поля lpReserved2 и cbReserved2. Эти два поля предоставляют возможность передачи произвольных данных от одного процесса к запускаемому без необходимости вызова VirtualAllocEx / WriteProcessMemory (прим.пер.: помните, что это хак; в 99% случаев намного предпочтительнее передавать данные через командную строку или анонимную память). Поле cbReserved2 является 16-битным целым и указывает размер буфера, на который указывает lpReserved2. Это означает, что lpReserved2 может иметь размер до 65535 байт.

Пример ниже иллюстрирует возможность передачи произвольного буфера от одного процесса другому. Когда процесс-B запускается процессом-A, он показывает MessageBox, говорящий "Hello from Process A!":
program ProcessA;

uses
  Windows,
  SysUtils;

var
  SI: TStartupInfo;
  PI: TProcessInformation;
  Buf: array[0..4095] of Char;
begin
  FillChar(Buf, SizeOf(Buf), 0);
  Buf := 'Hello from Process A!';

  FillChar(SI, SizeOf(SI), 0);
  SI.cb := SizeOf(SI);
  
  // Передача данных
  SI.lpReserved2 := @Buf;
  SI.cbReserved2 := SizeOf(Buf);
  
  if CreateProcess(nil, 'ProcessB.exe', 0, 0, 0, 0, 0, 0, SI, PI) then
  begin
    CloseHandle(PI.hProcess);
    CloseHandle(PI.hThread);
  end;
end.
program ProcessB;

uses
  Windows,
  SysUtils;

var
  SI: TStartupInfo;
begin
  FillChar(SI, SizeOf(SI), 0);
  SI.cb := SizeOf(SI);

  GetStartupInfo(SI);

  // Покажем, что послал нам процесс A
  MessageBox(0, PChar(SI.lpReserved2), 'Process B', MB_OK);
end.
Пока всё хорошо - мы узнали, про приятный способ передачи произвольных параметров между приложениями без необходимости использовать командную строку. Но тут есть проблема. В примере выше процесс B обязан быть собран абсолютно без поддержки C run-time (прим.пер.: как несложно сообразить, это применимо лишь к программам на MS VS, но не программам Delphi, если, конечно же, вы зачем-то вручную будете её включать в ваши программы). Причина этого довольно сложна, но я попытаюсь её объяснить.

Microsoft C run-time (включая Visual Studio.NET) использует эту возможность lpReserved2 для реализации C функций exec, system и spawn. Когда этими подпрограммами создаётся новый процесс, C run-time нужно передать дочернему процессу копии открытых файловых описателей (открытых через fopen/open, а не через CreateFile).

lpReserved2 используется как механизм для передачи этих файловых описателей между программами, использующими MSVC. До вызова CreateProcess подготавливается буфер для lpReserved2, который имеет такой формат (псевдо-код):
type
  TArgs = record
    count: DWORD;
    flags: array[1..count] of Byte;
    handles: array[1..count] of THandle;
  end;
Первое поле в буфере lpReserved2 является 32-битным целым числом передаваемых описателей. Сразу за ним идёт массив байт с этим числом элементов - флаги описателей. Эти флаги представляют собой файловый атрибуты, которые были использованы, когда файлы были открыты - т.е. вещи вроде "read-only", "write-append", "text-mode", "binary-mode" и т.п. А за этим массивом следует массив собственно описателей.

После этой структуры могут следовать любые данные - до 65536 байт. Поле cbReserved2 должно содержать суммарный размер передаваемых данных в байтах. Вот алгоритм, по которому строится буфер:
  1. Перечисляются все открытые "run-time" описатели файлов.
  2. Описатель Win32 каждого такого файла отмечается как "наследуемый" (inheritable).
  3. Число найденных описателей записывается в первое поле буфера для lpReserved2.
  4. Атрибуты каждого описателя записываются друг за другом в буфер.
  5. В буфер записываются файловые описатели.
  6. Наконец, вызывается CreateProcess с установленным в True полем bInheritHandles.
На этом этапе становится важным наличие поддержки C run-time. Когда запускается дочерний процесс, то в процессе своей инициализации до передачи управления в main() (прим.пер.: аналог главному begin/end в .dpr файле Delphi) инициализируется поддержка I/O. Как часть этого, вызывается GetStartupInfo и производится проверка, не указывает ли lpReserved2 на буфер. Если да, то C run-time, предполагая описанную выше структуру буфера, извлекает из неё описатели файлов - так что эти описатели становятся доступными новому процессу.
  1. Вызывается GetStartupInfo и проверяется, что lpReserved2 указывает на буфер.
  2. Извлекается первое 32-битное число - это будет число переданных описателей (размерность массивов).
  3. Текущее состояние ввода-вывода процесса инициализируется полученным числом описателей открытых файлов.
  4. Цикл по флагам восстанавливает состояние описателей.
  5. Цикл по описателям восстанавливает таблицу открытых файлов.
Проблема может быть теперь очевидна для более проницательных читателей. Любая программа, написанная с использованием Microsoft C run-time, будет при запуске проверять поле lpReserved2 - не удивительно, что это будут делать 90% C/C++ программ, написанных под Windows. Поле lpReserved2 также может использоваться и другими компиляторами для похожих целей.

Если вы хотите использовать lpReserved2 в ваших собственных программах, то вам нужно быть очень осторожными и точно убедиться, что либо вы используете описанный выше формат, либо не используете C run-time. Иначе дочерний процесс вылетит (или станет нестабилен) - потому что он будет ожидать буфер lpReserved2 в определённом формате.

Обойти эту проблему не сложно. Просто установите первые 4 байта буфера в ноль - указывая, что массив описателей имеет нулевую длину. Любая C run-time-подобная логика будет опущена. А вы можете расположить свои реальные данные после этого нулевого маркера.

Примечание: очевидно, этот метод не работает под 64-битной Windows Vista.

Итоги по CreateProcess
Это была скорее полноценная статья, чем мануал. Надеюсь, вы нашли что-то полезное или узнали о CreateProcess кое-что новое, что вы не знали раньше.

Я не прочь услышать любые ваши комментарии по этой статье. Если вы заметили мои ошибки или неточности - поправьте меня. И если у вас есть больше информации по этой теме - поделитесь ею!

[Добавлено 05.0.3.2014]: больше материала по CreateProcess.

7 комментариев:

  1. Александр, Здраствуйте.
    Подскажите.
    Как определить, запущен ли чужой процесс через ярлык (shortcut), и узнать путь к файлу ярлыка, который использовался для запуска чужого процесса?
    или Как узнать данные TStartupInfo.lpTitle чужого процесса?
    и возможно ли такое?

    ОтветитьУдалить
    Ответы
    1. Документированного способа нет. Если процесс ваш - налаживайте IPC. Если не ваш - остаются хаки. Внедряйтесь в процесс, вызывайте GetStartupInfo, передавайте куда надо.

      Альтернативно, посмотрите машинную реализацию GetStartupInfo - возможно, вам удастся обойтись GetThreadContext + ReadProcessMemory. Этот вариант, само собой, будет иметь высокие шансы поломаться в будущих версиях Windows.

      Удалить
  2. Спасибо за исчерпывающий ответ.

    ОтветитьУдалить
  3. Добрый день!
    Передо мной следующая задача:
    Из программы №1 (написана на делфи) запускается другая программа (№2, написана на c#), причем программа №1 остается доступной, т.е. пользователь может свернуть программу №2 и продолжить работать в №1. (нет WaitForInputIdle(hProcess, INFINITE);)

    Подскажите, пожалуйста, можно ли, чтобы программа №2 закрывалась при закрытии программы №1? В данный момент закрытие происходит с помощью команды TerminateProcess, которая запускается по событию закрытия приложения №1 (программа хранит hProcess открытого приложения №2), но это убийство процесса, что не устраивает. Потому что в приложении №2 могут происходить какие-либо изменения, и если закрыть приложение №2 обычным способом, сработает событие, выйдет диалоговое окно, предупреждающее, что "программа закрывается, а у вас есть несохраненные данные". Убийство же процесса не даст сработать событию на закрытие приложения №2.
    Можно ли сделать это автоматически, настроив правильно открытие приложения №2? Или же нужно каким-то образом получить Hadle окна приложения №2 и закрыть его с помощью SendMessage(hnd,WM_CLOSE,0,0);

    Подскажите, в какую сторону копать.
    Спасибо!

    ОтветитьУдалить
    Ответы
    1. Никто не вызываемому процессу получить PID своего родителя, открыть этот процесс и ждать его завершения.

      Удалить
  4. Добрый день, возможно ли получить данные которые возвращает запускаемая программа?

    ОтветитьУдалить
    Ответы
    1. Можно. Обмен файлами, реестр, IPC (пайпы, общая память), консольный ввод-вывод.

      Удалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку (поддерживается OpenID).

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

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