Kod samomodyfikujący się (x64)
Rozkaz w języku Asembler w formie kodu źródłowego składa się z mnemonika i opcjonalnych operandów.
Przykładowa instrukcja: xor rax, rax
Ten sam rozkaz w formie wykonywalnej to kod maszynowy, surowe bajty kodowane zgodnie z informacjami w dokumentacji danej rodziny procesorów. W prostych słowach: określonemu mnemonikowi odpowiada specyficzny kod operacyjny (ang. opcode).
Przykładowa instrukcja w postaci kodu maszynowego: 48 33 C0
Kod maszynowy programu utworzonego na potrzeby tego przykładu znajduje się w sekcji .text pliku PE. Pole o nazwie Pointer to Raw Data w nagłówku sekcji .text przechowuje adres w postaci przesunięcia (ang. offset), który wskazuje na początek kodu maszynowego w pliku PE na dysku.
Dla celów eksperymentalnych można odczytać fragment kodu maszynowego pod przesunięciem (ang. offset) o wartości 0x400. Na początku znajduje się wartość 48 33 C0, czyli jest to dokładnie wspomniana wcześniej instrukcja xor rax, rax, ale w postaci szesnastkowej (heksadecymalnej).
Po uruchomieniu pliku w formacie PE, zawarty w nim kod maszynowy jest wczytywany do pamięci i wykonywany, a bieżące instrukcje można obserwować w narzędziu typu debugger.
Z tego powodu modyfikacja własnego kodu przez program sprowadza się po prostu do wprowadzania zmian bezpośrednio w surowych bajtach kodu maszynowego.
Należy jednak wziąć pod uwagę kilka istotnych czynników.
Kody operacyjne (ang. opcode) instrukcji procesorów x64 mają różne rozmiary w bajtach. Podmiana rozkazów lub ich operandów musi to uwzględnić, aby nie uszkodzić kodu aplikacji. W przypadku gdy potrzebne jest wypełnienie pustego obszaru można zastosować instrukcję o kodzie operacyjnym 0x90, czyli nop (ang. no operation).
Domyślnie, ze względów bezpieczeństwa, sekcja z kodem posiada flagi pozwalające na odczyt (IMAGE_SCN_MEM_READ) oraz wykonanie (IMAGE_SCN_MEM_EXECUTE). Brak tutaj flagi pozwalającej na zapis.
Można jednak zastosować funkcję VirtualProtect, która zmienia prawa dostępu dla określonego fragmentu pamięci programu podczas jego działania.
Przykładowe wywołanie funkcji VirtualProtect w Asemblerze MASM x64 przedstawiono poniżej.
; (...)
lea r9, oldProtect
mov r8, PAGE_EXECUTE_READWRITE
mov rdx, CODE_LENGTH
lea rcx, mutable
call VirtualProtect
; (...)
Warto też pamiętać, aby po modyfikacjach wywołać funkcję FlushInstructionCache w celu odświeżenia pamięci podręcznej.
Mutable.Win64.Blau!4Z
Program Mutable.Win64.Blau!4Z to anomalia klasyfikowana jako zniekształcenie.
┌ Mutable.Win64.Blau!4Z
└─ src.txt
Poniższy fragment zawiera surowe bajty wstawione między instrukcje programu dyrektywą db.
; (...)
mutable:
db 00Fh, 033h, 0C9h
lea r8, e
db 00Fh
lea edx, e
db 0E8h
xor ecx, ecx
db 00Fh
lea eax, MessageBoxA
call rax
; (...)
Powoduje to, że dezasembler zwraca zniekształcony kod podczas podstawowej analizy statycznej.
Zniekształcony kod, ale w języku pseudo-C zwraca również dekompilator narzędzia Ghidra.
┌ Decompile (Ghidra)
└─ decompile.webp
Jednak anomalia posiada fragment kodu, który po uruchomieniu naprawia uszkodzone miejsca.
; (...)
lea rdx, mutable
mov byte ptr [rdx+00h], 04Dh
mov byte ptr [rdx+0Ah], 048h
mov byte ptr [rdx+11h], 048h
mov byte ptr [rdx+14h], 048h
; (...)
Modyfikacja kilku bajtów pod określonymi przesunięciami (ang. offset) powoduje, że podczas analizy dynamicznej kod wraca do czystej postaci.
Wykaz literatury
[1] Advanced Micro Devices, Inc. (AMD), AMD64 Architecture Programmer's Manual, 2024.
[2] Intel Corporation, The Intel 64 and IA-32 Architectures Software Developer's Manual, 2025.
[3] Microsoft Corporation, Microsoft Portable Executable and Common Object File Format Specification, 2025.