среда, 29 апреля 2015 г.

Анализ задачи №18 от Александра Алексеева (ака GunSmoker)

Кажется я первый раз попал в тупик.
Не то, чтобы я сильно умный, но и задачка — не "Балтика 9".

Первая часть задачи выглядела вот так:

Что не так с этим кодом?

procedure TForm1.Button1Click(Sender: TObject);
var
  Wnd: HWND;
 
  function EnumWindowsProc(const AWnd: HWND; const AParam: LPARAM): BOOL; stdcall;
  begin
    if AWnd = Wnd then
      Caption := 'OK';
    Result := True;
  end;
 
begin
  Wnd := Handle;
  EnumWindows(@EnumWindowsProc, 0);
end;

Приступим.



Конечно, первое что бросается в глаза — вызов калбэка, в котором происходит обращение к переменной Wnd.

Жалко, что Delphi позволяет вообще такому компилироваться.
Правда это обусловлено не совсем верной декларацией самой функции EnumWindows.

Если бы она была объявлена вот так:

type
  TFNWndEnumProc = function(AWnd: HWND; AParam: LPARAM): BOOL; stdcall;

  function EnumWindows(lpEnumFunc: TFNWndEnumProc;
    lParam: LPARAM): BOOL; stdcall; external user32 name 'EnumWindows';

То ошибка была бы выявлена еще на этапе компиляции и Delphi выдала бы сообщение:
[dcc64 Error] Unit1.pas(49): E2094 Local procedure/function 'EnumWindowsProc' assigned to procedure variable
При этом вызывать нужно без взятия указателя, т.е. вот так:

  EnumWindows(EnumWindowsProc, 0);

Если же мы будем получать адрес функции EnumWindowsProc посредством оператора "@", то даже такая декларация не поможет, т.к. в данном случае мы будем работать с нетипизированным указателем и все проверки на этапе компиляции будут отключены.

Конечно можно включить в настройках компилятора флаг "Typed @ operator" и получить сообщение:
[dcc64 Error] Unit1.pas(45): E2010 Incompatible types: 'TFNWndEnumProc' and 'Pointer'
Но это уже как-то избыточно :)

Впрочем, так как объявление у EnumWindows другое, и данный код все же компилируется, попробуем разобраться с ним по пунктам:
  1. Переменная Wnd объявлена как локальная и память под нее выделена на стеке, фрейм которого принадлежит процедуре TForm1.Button1Click.
  2. EnumWindowsProc - это калбэк процедура, с своим собственным стеком, вызываемая не напрямую через EnumWindows, а через еще кучу пертрубаций в User32.dll
  3. EnumWindowsProc пытается получить доступ к Wnd и компилятор пытается этому помочь, ориентируясь на код.
Пардоньте - сейчас будет много ассемблера, но демонстрирую:

Вот так выглядит вызов EnumWindows:



Тут нас интересует значение, помещенное на стек по адресу EBP-4, грубо говоря хэдл, полученный вызовом GetHandle.
На стеке он будет размещен примерно вот так:



Число 12F558 - это адрес локальной переменной Wnd, а 16051A - это хэндл главной формы, т.е. значение самой переменной. Запомним адрес 12F558 и посмотрим что произойдет при вызове калбэка EnumWindowsProc.



А вот это уже настоящая беда.

После генерации стекового фрейма (push+mov), EBP+8 указывает на параметр AWnd калбэка, Но посмотрите как происходит обращение к внешней переменной Wnd, с которой потом будет происходить сравнение.

005C734B 8B4510           mov eax,[ebp+$10]
005C734E 8B40FC           mov eax,[eax-$04]

Delphi компилятор, ошибшись, сгенерировал асм код получения указателя на верхушку фреймового стека, полагаясь на то, что мы все еще находимся в исполнении процедуры Button1Click, после чего ошибочно начал читать значение локальной переменной Wnd по адресу EAX-4.

Это грозит - глобальным AV, ведь текущий стек не принадлежит Button1Click и мы реально можем влететь на ошибку как при получении верхушки фрейма, а если повезло и не упали, то упадем на чтении якобы Wnd с битого адреса (если повезет).

Показываю с использованием карты памяти процесса:


Как вы видите, EAX после выполнения первой инструкции, вместо правильного адреса на стеке указывает куда-то вглубь памяти процесса, на которую отмаплен текущий исполняемый файл.

Можно сказать повезло, могли попасть и в секцию проверки битых указателей (первые 64 кб памяти процесса).

Сами понимаете что вторая строка (получение значения Wnd) уже как минимум не будет корректной, при работе с такими данными.
А нам нужен был адрес 12F558 - но не судьба.

Впрочем, перейдем к пункту за номером 4.
А именно к строчке:

      Caption := 'OK';

Конечно-же в калбэк не передается перемеменна Self, но Delphi компилер не смог предположить такого цимуса, и даже в этом случае пытается выйти на нее.

005C7356 8B4510           mov eax,[ebp+$10]
005C7359 8B40F8           mov eax,[eax-$08]

Понятно, что такого у него не получится, но... если вдруг он все-же получит какой-то не совсем убитый адрес, то произойдет глобальный бадабум на вызове сеттера Caption, ибо произойдет вызов WindowProc на не понятно каком окне (я даже про VMT пока не говорю).

Часть вторая


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

procedure TForm1.Button1Click(Sender: TObject);
 
  function EnumWindowsProc(const AWnd: HWND; const AParam: LPARAM): BOOL; stdcall;
  var
    Wnd: HWND;
  begin
    Wnd := HWND(AParam);
    if AWnd = Wnd then
      Result := True
    else
      Result := False;
  end;
 
var
  Wnd: HWND;
begin
  Wnd := Handle;
  EnumWindows(@EnumWindowsProc, LPARAM(Wnd));
end;

Сразу скажу — на мой взгляд код правильный, но раз есть заковыка (а иначе и не было бы такой задачи поставлено), значит надо анализировать.
Единственный момент с условием выхода из EnumWindowsProc, ибо практически гарантированно, что первое найденное окно не будет хэндлом формы, что означает завершение работы EnumWindowsProc после первого-же сравнения (по False результату).

Повтыкав минут двадцать в данный код и не обнаружив ошибки, я выкатил его всему нашему IT отделу, с целью - может коллективный разум осилит?

Через час мы сдались и я начал анализировать уже с точки зрения реверсера.

Итак, что мы имеем:

sdf.pas.31: begin
005C7348 55               push ebp
005C7349 8BEC             mov ebp,esp
sdf.pas.32: Wnd := HWND(AParam);
005C734B 8B450C           mov eax,[ebp+$0c]
sdf.pas.33: if AWnd = Wnd then
005C734E 3B4508           cmp eax,[ebp+$08]
005C7351 7505             jnz $005c7358
sdf.pas.34: Result := True
005C7353 83C8FF           or eax,-$01
005C7356 EB02             jmp $005c735a
sdf.pas.36: Result := False;
005C7358 33C0             xor eax,eax
sdf.pas.37: end;
005C735A 5D               pop ebp
005C735B C20800           ret $0008
005C735E 8BC0             mov eax,eax
sdf.pas.41: begin
005C7360 55               push ebp
005C7361 8BEC             mov ebp,esp
sdf.pas.42: Wnd := Handle;
005C7363 E8248EF5FF       call TWinControl.GetHandle
sdf.pas.43: EnumWindows(@EnumWindowsProc, LPARAM(Wnd));
005C7368 50               push eax
005C7369 6848735C00       push $005c7348
005C736E E879BBE4FF       call EnumWindows
sdf.pas.44: end;
005C7373 5D               pop ebp
005C7374 C3               ret 

Первичный анализ асм кода показывает, что использованные локальные перменные Wnd выкинуты оптимизатором, т.е. вот такой вот код будет выдавать абсолютно идентичный асм листинг:

procedure TForm1.Button1Click(Sender: TObject);

  function EnumWindowsProc(const AWnd: HWND; const AParam: LPARAM): BOOL; stdcall;
  begin
    if AWnd = HWND(AParam) then
      Result := True
    else
      Result := False;
  end;

begin
  EnumWindows(@EnumWindowsProc, LPARAM(Handle));
end;

Значит заковыка не в использовании локальных переменных.
Вариант с ошибками каста HWND/LPARAM отпадает, тут все правильно.
Вариант с выносом калбэка за тело процедуры асм код не меняет (да и смысл?).
Вариант с неверной декларацией EnumWindowsProc тоже отпадает (спецом исходники винды поднял - может там какой нюанс крылся?)
Значит остается последний вариант: может что-то с условием, как-то странно оно выглядит.

Переписываю вот так:

procedure TForm1.Button1Click(Sender: TObject);

  function EnumWindowsProc(const AWnd: HWND; const AParam: LPARAM): BOOL; stdcall;
  begin
    Result := AWnd = HWND(AParam);
  end;

begin
  EnumWindows(@EnumWindowsProc, LPARAM(Handle));
end;

Выхлоп:

sdf.pas.29: begin
005C7348 55               push ebp
005C7349 8BEC             mov ebp,esp
sdf.pas.30: Result := AWnd = HWND(AParam);
005C734B 8B4508           mov eax,[ebp+$08]
005C734E 3B450C           cmp eax,[ebp+$0c]
005C7351 0F94C0           setz al
005C7354 F6D8             neg al
005C7356 1BC0             sbb eax,eax
sdf.pas.31: end;
005C7358 5D               pop ebp
005C7359 C20800           ret $0008
sdf.pas.33: begin
005C735C 55               push ebp
005C735D 8BEC             mov ebp,esp
005C735F 53               push ebx
005C7360 8BD8             mov ebx,eax
sdf.pas.34: EnumWindows(@EnumWindowsProc, LPARAM(Handle));
005C7362 8BC3             mov eax,ebx
005C7364 E8238EF5FF       call TWinControl.GetHandle
005C7369 50               push eax
005C736A 6848735C00       push $005c7348
005C736F E878BBE4FF       call EnumWindows
sdf.pas.35: end;
005C7374 5B               pop ebx
005C7375 5D               pop ebp
005C7376 C3               ret 

И опять нет ничего странного.

Тут я сдулся, ибо если последний вариант кода считать верным (а он верный, иначе даже в MSDN ошибка) то я категорически не понимаю - где здесь тонкий нюанс?

Но раз GunSmoker выкатил такую задачу - значит что-то тут есть еще такое, а я лично сдаюсь :)

UPDATE:

Ан нет - рано еще здаваться, как оказалось дело было в том что я забыл про 64 бита.

Смотрим ASM код функции EnumWindowsProc:

00000000006960A0 55               push rbp
00000000006960A1 4883EC10         sub rsp,$10
00000000006960A5 488BEC           mov rbp,rsp
00000000006960A8 48894D20         mov [rbp+$20],rcx // сохраняем значение AWnd
00000000006960AC 48895528         mov [rbp+$28],rdx // сохраняем значение AParam
00000000006960B0 4C894530         mov [rbp+$30],r8  // и вот пошел ошибочный код, сохраняем R8
hgfjgfjh.pas.55: Wnd := HWND(AParam);
00000000006960B4 488B4530         mov rax,[rbp+$30] // перемещаем значение R8 на верхушку стека
00000000006960B8 48894500         mov [rbp+$00],rax
hgfjgfjh.pas.56: if AWnd = Wnd then
00000000006960BC 488B4528         mov rax,[rbp+$28] // читаем значение AParam
00000000006960C0 483B4500         cmp rax,[rbp+$00] // и сравниваем его с R8, а нужно с AWnd
00000000006960C4 7509             jnz EnumWindowsProc + $2F
hgfjgfjh.pas.57: Result := True
00000000006960C6 C7450CFFFFFFFF   mov [rbp+$0c],$ffffffff
00000000006960CD EB07             jmp EnumWindowsProc + $36
hgfjgfjh.pas.59: Result := False;
00000000006960CF C7450C00000000   mov [rbp+$0c],$00000000
hgfjgfjh.pas.60: end;
00000000006960D6 8B450C           mov eax,[rbp+$0c]
00000000006960D9 488D6510         lea rsp,[rbp+$10]
00000000006960DD 5D               pop rbp
00000000006960DE C3               ret 

Вот оно что, Михалыч.

Причина простая, по умолчанию параметры передаются через регистры RCX, RDX, R8 и т.п.
Так вот для Nested функций, Delphi в регистр RCX помещает адрес вершины стека.
Таким образом оставшиеся два параметра идут в RDX и R8.
А код внутри EnumWindows этого не делает и по честному передает оба параметра через RCX + RDX.
Получается что мы работаем совершенно убитыми данными.

Как избавится от такого поведения?
Ответ прост - вынести функцию EnumWindowsProc за пределы процедуры TForm1.Button1Click.

function EnumWindowsProc(const AWnd: HWND; const AParam: LPARAM): BOOL; stdcall;
var
  Wnd: HWND;
begin
  Wnd := HWND(AParam);
  if AWnd = Wnd then
    Result := True
  else
    Result := False;
end;

procedure TForm1.Button1Click(Sender: TObject);
var
  Wnd: HWND;
begin
  Wnd := Handle;
  EnumWindows(@EnumWindowsProc, LPARAM(Wnd));
end;

После чего регистр RCX не будет задействован и сгенерируется правильный ASM код:

0000000000696030 55               push rbp
0000000000696031 4883EC10         sub rsp,$10
0000000000696035 488BEC           mov rbp,rsp
0000000000696038 48894D20         mov [rbp+$20],rcx // сохраняем значение AWnd
000000000069603C 48895528         mov [rbp+$28],rdx // сохраняем значение AParam
hgfjgfjh.pas.31: Wnd := HWND(AParam);
0000000000696040 488B4528         mov rax,[rbp+$28] // перемещаем значение AParam на верхушку стека
0000000000696044 48894500         mov [rbp+$00],rax
hgfjgfjh.pas.32: if AWnd = Wnd then
0000000000696048 488B4520         mov rax,[rbp+$20] // читаем значение AWnd
000000000069604C 483B4500         cmp rax,[rbp+$00] // и сравниваем его с AParam 
0000000000696050 7509             jnz EnumWindowsProc + $2B
hgfjgfjh.pas.33: Result := True
0000000000696052 C7450CFFFFFFFF   mov [rbp+$0c],$ffffffff
0000000000696059 EB07             jmp EnumWindowsProc + $32
hgfjgfjh.pas.35: Result := False;
000000000069605B C7450C00000000   mov [rbp+$0c],$00000000
hgfjgfjh.pas.36: end;
0000000000696062 8B450C           mov eax,[rbp+$0c]
0000000000696065 488D6510         lea rsp,[rbp+$10]
0000000000696069 5D               pop rbp
000000000069606A C3               ret

Собственно, как мне подсказали, этот момент описан в документации (о чем я не знал).
Nested procedures and functions (routines declared within other routines) cannot be used as procedural values, nor can predefined procedures and functions.
http://docwiki.embarcadero.com/RADStudio/XE8/en/Procedural_Types#Method_Pointers

Такие вот пироги :)

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

  1. Слишком сильно подошёл, там всё проще :) будешь ответом разочарован.

    ОтветитьУдалить
    Ответы
    1. Мне что-то такое и видится - но не подсказывай, еще время на решение у меня есть :)

      Удалить
  2. Некоторые функции WinAPI слишком прихотливы к возвращаемым callback-ами значениям: они хотят видеть False=0 и True=1, а Delphi выдаёт False=0 и True=-1 (минус один). Но насколько я помню, EnumWindows такой проблемы не имеет.

    ОтветитьУдалить
    Ответы
    1. В данном случае для True генерируется именно -1

      первый вариант:
      or eax,-$01

      второй:
      setz al
      neg al
      sbb eax,eax

      Удалить
    2. Соответственно -1 равный FFFFFFFF на операции сравнения всегда снимает ZF флг

      Удалить
  3. Речь о том, что некоторые API делают проверку с помощью "if (res=TRUE) ...", а не "if (res) ...".
    Примеры: EnumSystemLocales, CreateMutex, IAudioEndpointVolume.SetMute

    ОтветитьУдалить
    Ответы
    1. Delphi знает, что BOOL - это не совсем Boolean

      Удалить
    2. Разумеется "не совсем": у них даже длина разная. Проблема в том, что, по неведомым мне причинам, BOOL(True) в Delphi равен минус единице (как в VB), а в C (и WinAPI на нём написанном) единице. Про разные bool-ы:
      http://blogs.msdn.com/b/oldnewthing/archive/2004/12/22/329884.aspx
      http://blogs.msdn.com/b/oldnewthing/archive/2011/03/28/10146459.aspx
      http://blogs.msdn.com/b/ericlippert/archive/2004/07/15/184431.aspx

      Удалить
    3. Размер результат возврата функции (BOOL/Boolean) не имеет значения (EAX всегда 4 байта/или RAX - 8), а вот на разницу проверки Вы верно указали.
      В одном случае будет проверятся первый бит результата, в другом первый байт (если не трогаем OLE) - такой момент известен, но не относится к рассматриваемой ситуации.

      Удалить
  4. Ответы
    1. Вызов принят - Boolean :)
      На этом остановимся или раскатать идею? :)

      Удалить
    2. "Размер результат возврата функции (BOOL/Boolean) не имеет значения (EAX всегда 4 байта/или RAX - 8)" ;)

      Удалить
    3. Ну... слегка маханул, конечно. Случаи разные бывают :)

      Удалить
    4. Сань, так мне как, на третью задачу ответ давать или попридержим?

      Удалить
    5. Ну, на твоё усмотрение. Я ж её специально тебе написал :))) Тут если кто все комменты читает - и так смекнёт.

      Удалить
    6. Принято, на третью задачу ответ не делаю.
      Но, Саш - вторая задача это было круто :)
      Давно я так не разминал мозги :)

      Удалить