Introduction
Phantom pass is a collection of LLVM IR and machine code level obfuscation passes. The techniques are either extracted from reversed malware samples (e.g. Mirai, Hancitor and XLoader) or obtained via OSINT. The passes are primarily intended for AArch64, but some also work on other architectures. This book provides the supplementary documentation.
The source code is available here.
🚧 The book is still under construction. New chapters will be added and the existing ones might be modified.
Prerequisites
macOS
LLVM
There is already a version of LLVM preinstalled but it does not contain LLVM development tools (such as opt). For this reason, we only add the missing tools to the path to avoid conflicts. (Alternatively, <llvm-path>/bin can be added to the path, but this will shadow the preinstalled LLVM tools.)
brew
$ brew install llvm
$ sudo ln -s /opt/homebrew/opt/llvm/bin/opt /usr/local/bin/opt
$ sudo ln -s /opt/homebrew/opt/llvm/bin/llc /usr/local/bin/llc
$ sudo ln -s /opt/homebrew/opt/llvm/bin/llvm-config /usr/local/bin/llvm-config
Build from source
$ git clone https://github.com/llvm/llvm-project.git
$ cd llvm-project
$ cmake -S llvm -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DLLVM_PARALLEL_LINK_JOBS=1 -DLLVM_ENABLE_PROJECTS="clang"
$ ninja -C build
$ sudo ln -s /<path>/llvm-project/build/bin/opt /usr/local/bin/opt
$ sudo ln -s /<path>/llvm-project/build/bin/llc /usr/local/bin/llc
$ sudo ln -s /<path>/llvm-project/build/bin/llvm-config /usr/local/bin/llvm-config
Refer to the GettingStarted guide for more information.
Boost
$ brew install boost
LibreSSL or OpenSSL
$ brew install libressl
or:
$ brew install openssl
Ghidra
$ wget https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_11.3.2_build/ghidra_11.3.2_PUBLIC_20250415.zip
Alternatively, Ghidra can be built and installed from source.
VS Code
$ brew install --cask visual-studio-code
Press Cmd + Shift + P and run the C/C++: Edit Configurations (JSON) command which will create the .vscode/c_cpp_properties.json file. Add the following include paths:
{
"configurations": [
{
"name": "Mac",
"includePath": [
"${workspaceFolder}/**",
"/opt/homebrew/opt/llvm/include",
"/opt/homebrew/opt/llvm/include/llvm",
"/opt/homebrew/opt/llvm/include/llvm/IR",
"/opt/homebrew/opt/llvm/include/llvm/Passes",
"/opt/homebrew/opt/llvm/include/llvm/Support",
"/opt/homebrew/include"
],
"defines": [],
"compilerPath": "/usr/bin/clang",
"cStandard": "c17",
"cppStandard": "c++17",
"intelliSenseMode": "macos-clang-arm64"
}
],
"version": 4
}
References
- LLVM and API reference
- Writing an LLVM Pass
- LLVM’s Analysis and Transform Passes
- LLVM Programmer’s Manual
- LLVM Diagnostics Reference
- GDB to LLDB command map
- XNU system call master file
Hello, world!
A simple LLVM pass that inserts a puts("Hello, world!") call into main().
Known limitations:
putsis only declared by the pass (not defined)
The source code is available here.
Generate the IR for our empty main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.c -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
; Function Attrs: mustprogress nofree norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
ret i32 0
}
attributes #0 = { mustprogress nofree norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="hello-world" -S test.ll -o obf.ll
HelloWorldPass: Successfully injected puts("Hello, world!") into main
Check the output, note that the Hello, world! string and puts() function call have been added:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@0 = private unnamed_addr constant [14 x i8] c"Hello, world!\00", align 1
; Function Attrs: mustprogress nofree norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
%1 = call i32 @puts(ptr @0)
ret i32 0
}
declare i32 @puts(ptr)
attributes #0 = { mustprogress nofree norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
Hello, world!
String XOR encryption (with malloc)
An LLVM pass that replaces C strings with XOR-encrypted versions and decrypts them at runtime. The decrypted strings are stored in heap-allocated blocks. The pass automatically replaces the original string references with the encrypted ones, implements the decrypt function and calls it before the string is used.
Known limitations:
- only C strings are supported at this time
- the decrypted strings are not re-encrypted after use, meaning they stay unencrypted in the memory
- the allocated memory blocks are not freed (only when the process exits and the OS reclaims them)
- increased code size (although small compared to more complex encryption methods such as RC4)
- increased runtime penalty (although small compared to more complex encryption methods such as RC4)
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.c -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [14 x i8] c"Hello, world!\00", align 1
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
%1 = tail call i32 @puts(ptr noundef nonnull dereferenceable(1) @.str)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @puts(ptr noundef readonly captures(none)) local_unnamed_addr #1
attributes #0 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="string-xor-encryption" -S test.ll -o obf.ll
StringEncryptionPass: Encrypted 1 strings
Check the output, note that the Hello, world! string is encrypted and the __obf_decrypt function has been added:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@__obf_str_1427455572 = private constant [14 x i8] c"Q|uuv59nvku}8\19"
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
%1 = call ptr @__obf_decrypt(ptr @__obf_str_1427455572, i8 25, i64 14)
%2 = tail call i32 @puts(ptr noundef nonnull dereferenceable(1) %1)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @puts(ptr noundef readonly captures(none)) local_unnamed_addr #1
define private ptr @__obf_decrypt(ptr %enc_ptr, i8 %key, i64 %len) {
entry:
%dec_ptr = call ptr @malloc(i64 %len)
br label %loop_header
loop_header: ; preds = %loop_body, %entry
%phi_idx = phi i64 [ 0, %entry ], [ %next_idx, %loop_body ]
%cond = icmp ult i64 %phi_idx, %len
br i1 %cond, label %loop_body, label %loop_exit
loop_body: ; preds = %loop_header
%src_gep = getelementptr i8, ptr %enc_ptr, i64 %phi_idx
%dst_gep = getelementptr i8, ptr %dec_ptr, i64 %phi_idx
%enc_byte = load i8, ptr %src_gep, align 1
%dec_byte = xor i8 %enc_byte, %key
store i8 %dec_byte, ptr %dst_gep, align 1
%next_idx = add i64 %phi_idx, 1
br label %loop_header
loop_exit: ; preds = %loop_header
ret ptr %dec_ptr
}
declare ptr @malloc(i64)
attributes #0 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
Hello, world!
String base64 encoding
LLVM pass that replaces C strings with base64-encoded versions and decodes them at runtime. The decoded string is stored in the original encoded global variable. The pass automatically implements the decode function and calls it before the string is used.
Known limitations:
- only C strings are supported at this time
- the decoded strings are not re-encoded after use, meaning they stay decoded in the memory
- increased code size
- increased runtime penalty
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.c -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [14 x i8] c"Hello, world!\00", align 1
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
%1 = tail call i32 @puts(ptr noundef nonnull dereferenceable(1) @.str)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @puts(ptr noundef readonly captures(none)) local_unnamed_addr #1
attributes #0 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -I/opt/homebrew/include -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="string-base64-encode" -S test.ll -o obf.ll
StringBase64EncodePass: Encoded 1 strings
Check the output, note that the Hello, world! string is base64 encoded, and the __obf_base64_decode function and the __obf_char_table global variable have been added:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@__obf_char_table = internal constant [256 x i32] [i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 62, i32 -1, i32 -1, i32 -1, i32 63, i32 52, i32 53, i32 54, i32 55, i32 56, i32 57, i32 58, i32 59, i32 60, i32 61, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 0, i32 1, i32 2, i32 3, i32 4, i32 5, i32 6, i32 7, i32 8, i32 9, i32 10, i32 11, i32 12, i32 13, i32 14, i32 15, i32 16, i32 17, i32 18, i32 19, i32 20, i32 21, i32 22, i32 23, i32 24, i32 25, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 26, i32 27, i32 28, i32 29, i32 30, i32 31, i32 32, i32 33, i32 34, i32 35, i32 36, i32 37, i32 38, i32 39, i32 40, i32 41, i32 42, i32 43, i32 44, i32 45, i32 46, i32 47, i32 48, i32 49, i32 50, i32 51, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1, i32 -1]
@__obf_str_1752770893 = private global [21 x i8] c"SGVsbG8sIHdvcmxkIQA=\00"
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
call void @__obf_base64_decode(ptr @__obf_str_1752770893, i64 20)
%1 = tail call i32 @puts(ptr noundef nonnull dereferenceable(1) @__obf_str_1752770893)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @puts(ptr noundef readonly captures(none)) local_unnamed_addr #1
define private void @__obf_base64_decode(ptr %enc_ptr, i64 %len) {
entry:
%val = alloca i32, align 4
store i32 0, ptr %val, align 4
%bits = alloca i32, align 4
store i32 -8, ptr %bits, align 4
%out_pos = alloca i64, align 8
store i64 0, ptr %out_pos, align 8
br label %loop_header
loop_header: ; preds = %loop_inc, %entry
%phi_idx = phi i64 [ 0, %entry ], [ %next_idx, %loop_inc ]
%cond = icmp ult i64 %phi_idx, %len
br i1 %cond, label %loop_body, label %loop_exit
loop_body: ; preds = %loop_header
%input_gep = getelementptr inbounds i8, ptr %enc_ptr, i64 %phi_idx
%char = load i8, ptr %input_gep, align 1
%table_gep = getelementptr inbounds [256 x i32], ptr @__obf_char_table, i32 0, i8 %char
%tc = load i32, ptr %table_gep, align 4
%val_loaded = load i32, ptr %val, align 4
%val_shifted = shl i32 %val_loaded, 6
%val_new = add i32 %val_shifted, %tc
store i32 %val_new, ptr %val, align 4
%bits_loaded = load i32, ptr %bits, align 4
%bits_new = add i32 %bits_loaded, 6
store i32 %bits_new, ptr %bits, align 4
%bits_check = icmp sge i32 %bits_new, 0
br i1 %bits_check, label %store_byte, label %loop_inc
loop_exit: ; preds = %loop_header
ret void
store_byte: ; preds = %loop_body
%out_pos_loaded = load i64, ptr %out_pos, align 8
%shifted = lshr i32 %val_new, %bits_new
%masked = and i32 %shifted, 255
%byte = trunc i32 %masked to i8
%output_gep = getelementptr inbounds i8, ptr %enc_ptr, i64 %out_pos_loaded
store i8 %byte, ptr %output_gep, align 1
%out_pos_inc = add i64 %out_pos_loaded, 1
store i64 %out_pos_inc, ptr %out_pos, align 8
%bits_dec = sub i32 %bits_new, 8
store i32 %bits_dec, ptr %bits, align 4
br label %loop_inc
loop_inc: ; preds = %store_byte, %loop_body
%next_idx = add i64 %phi_idx, 1
br label %loop_header
}
attributes #0 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
Hello, world!
String XOR encryption (with global)
An LLVM pass that replaces C strings with XOR-encrypted versions and decrypts them at runtime. The decrypted string is stored in the original encrypted global variable. The pass automatically implements the decrypt function and calls it before the string is used.
Known limitations:
- only C strings are supported at this time
- the decrypted strings are not re-encrypted after use, meaning they stay unencrypted in the memory
- increased code size (although small compared to more complex encryption methods such as RC4)
- increased runtime penalty (although small compared to more complex encryption methods such as RC4)
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.c -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [14 x i8] c"Hello, world!\00", align 1
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
%1 = tail call i32 @puts(ptr noundef nonnull dereferenceable(1) @.str)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @puts(ptr noundef readonly captures(none)) local_unnamed_addr #1
attributes #0 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="string-xor-encryption" -S test.ll -o obf.ll
StringEncryptionPass: Encrypted 1 strings
Check the output, note that the Hello, world! string is encrypted and the __obf_decrypt function has been added:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@__obf_str_1297094926 = private global [14 x i8] c"Wzssp3?hpms{>\1F"
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
%1 = call ptr @__obf_decrypt(ptr @__obf_str_1297094926, i8 31, i64 14)
%2 = tail call i32 @puts(ptr noundef nonnull dereferenceable(1) %1)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @puts(ptr noundef readonly captures(none)) local_unnamed_addr #1
define private ptr @__obf_decrypt(ptr %enc_ptr, i8 %key, i64 %len) {
entry:
br label %loop_header
loop_header: ; preds = %loop_body, %entry
%phi_idx = phi i64 [ 0, %entry ], [ %next_idx, %loop_body ]
%cond = icmp ult i64 %phi_idx, %len
br i1 %cond, label %loop_body, label %loop_exit
loop_body: ; preds = %loop_header
%src_gep = getelementptr i8, ptr %enc_ptr, i64 %phi_idx
%dst_gep = getelementptr i8, ptr %enc_ptr, i64 %phi_idx
%enc_byte = load i8, ptr %src_gep, align 1
%dec_byte = xor i8 %enc_byte, %key
store i8 %dec_byte, ptr %dst_gep, align 1
%next_idx = add i64 %phi_idx, 1
br label %loop_header
loop_exit: ; preds = %loop_header
ret ptr %enc_ptr
}
attributes #0 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
Hello, world!
String RC4 encryption
An LLVM pass that replaces C strings with RC4-encrypted versions and decrypts them at runtime. The decrypted string is stored in the original encrypted global variable. The pass automatically implements the decrypt function and calls it before the string is used.
Known limitations:
- only C strings are supported at this time
- the decrypted strings are not re-encrypted after use, meaning they stay unencrypted in the memory
- the RC4 key (
"MySecretKey") is hardcoded into the binary - increased code size
- increased runtime penalty
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.c -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [14 x i8] c"Hello, world!\00", align 1
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
%1 = tail call i32 @puts(ptr noundef nonnull dereferenceable(1) @.str)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @puts(ptr noundef readonly captures(none)) local_unnamed_addr #1
attributes #0 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -Wall -Wextra -Wpedantic -Werror -Wno-deprecated-declarations -lcrypto -L/opt/homebrew/opt/libressl/lib -I/opt/homebrew/include -isystem $(llvm-config --includedir) -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="string-rc4-encryption" -S test.ll -o obf.ll
StringEncryptionPass: Encrypted 1 strings
Check the output, note that the Hello, world! string is encrypted and the __obf_decrypt function has been added:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@__obf_str_167793552 = private global [14 x i8] c"Ic\BA\ED\B7\A4\19+\D2\AE\AB'}\07"
@key = private constant [11 x i8] c"MySecretKey"
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
call void @__obf_decrypt(ptr @key, i32 11, ptr @__obf_str_167793552, i32 14)
%1 = tail call i32 @puts(ptr noundef nonnull dereferenceable(1) @__obf_str_167793552)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @puts(ptr noundef readonly captures(none)) local_unnamed_addr #1
define private void @__obf_decrypt(ptr %key_ptr, i32 %key_len, ptr %data_ptr, i32 %data_len) {
entry:
%sbox = alloca [256 x i8], align 1
%j = alloca i32, align 4
store i32 0, ptr %j, align 4
%t = alloca i32, align 4
br label %loop_header
loop_header: ; preds = %loop_body, %entry
%index_phi = phi i32 [ 0, %entry ], [ %next_index_phi, %loop_body ]
%cond = icmp ult i32 %index_phi, 256
br i1 %cond, label %loop_body, label %loop_exit
loop_body: ; preds = %loop_header
%sbox_gep = getelementptr inbounds [256 x i8], ptr %sbox, i32 0, i32 %index_phi
%index_trunc = trunc i32 %index_phi to i8
store i8 %index_trunc, ptr %sbox_gep, align 1
%next_index_phi = add i32 %index_phi, 1
br label %loop_header
loop_exit: ; preds = %loop_header
br label %loop_header2
loop_header2: ; preds = %loop_body2, %loop_exit
%index_phi2 = phi i32 [ 0, %loop_exit ], [ %next_index_phi2, %loop_body2 ]
%cond2 = icmp ult i32 %index_phi2, 256
br i1 %cond2, label %loop_body2, label %loop_exit2
loop_body2: ; preds = %loop_header2
%j_loaded = load i32, ptr %j, align 4
%sbox_gep_i2 = getelementptr inbounds [256 x i8], ptr %sbox, i32 0, i32 %index_phi2
%s_i2_loaded = load i8, ptr %sbox_gep_i2, align 1
%s_i2_ext = zext i8 %s_i2_loaded to i32
%mod0 = srem i32 %index_phi2, %key_len
%key_gep = getelementptr i8, ptr %key_ptr, i32 %mod0
%key_loaded = load i8, ptr %key_gep, align 1
%sum0 = add i32 %j_loaded, %s_i2_ext
%key_loaded_ext = sext i8 %key_loaded to i32
%sum1 = add i32 %sum0, %key_loaded_ext
%mod1 = srem i32 %sum1, 256
store i32 %mod1, ptr %j, align 4
store i32 %s_i2_ext, ptr %t, align 4
%j_loaded1 = load i32, ptr %j, align 4
%sbox_gep_j = getelementptr inbounds [256 x i8], ptr %sbox, i32 0, i32 %j_loaded1
%sbox_j_loaded = load i8, ptr %sbox_gep_j, align 1
store i8 %sbox_j_loaded, ptr %sbox_gep_i2, align 1
%t_loaded = load i32, ptr %t, align 4
%t_trunc = trunc i32 %t_loaded to i8
store i8 %t_trunc, ptr %sbox_gep_j, align 1
%next_index_phi2 = add i32 %index_phi2, 1
br label %loop_header2
loop_exit2: ; preds = %loop_header2
%i3 = alloca i32, align 4
store i32 0, ptr %i3, align 4
%j3 = alloca i32, align 4
store i32 0, ptr %j3, align 4
br label %loop_header3
loop_header3: ; preds = %loop_body3, %loop_exit2
%k_phi3 = phi i32 [ 0, %loop_exit2 ], [ %next_k_phi3, %loop_body3 ]
%cond3 = icmp ult i32 %k_phi3, %data_len
br i1 %cond3, label %loop_body3, label %loop_exit3
loop_body3: ; preds = %loop_header3
%i3_loaded = load i32, ptr %i3, align 4
%i3_inc = add i32 %i3_loaded, 1
%mod2 = srem i32 %i3_inc, 256
store i32 %mod2, ptr %i3, align 4
%j3_loaded = load i32, ptr %j3, align 4
%i3_loaded2 = load i32, ptr %i3, align 4
%sbox_gep_i3 = getelementptr inbounds [256 x i8], ptr %sbox, i32 0, i32 %i3_loaded2
%sbox_i3_loaded = load i8, ptr %sbox_gep_i3, align 1
%sbox_i3_ext = zext i8 %sbox_i3_loaded to i32
%sum2 = add i32 %j3_loaded, %sbox_i3_ext
%mod3 = srem i32 %sum2, 256
store i32 %mod3, ptr %j3, align 4
store i32 %sbox_i3_ext, ptr %t, align 4
%j3_loaded3 = load i32, ptr %j3, align 4
%sbox_gep_j3 = getelementptr inbounds [256 x i8], ptr %sbox, i32 0, i32 %j3_loaded3
%sbox_j3_loaded = load i8, ptr %sbox_gep_j3, align 1
store i8 %sbox_j3_loaded, ptr %sbox_gep_i3, align 1
%t_loaded4 = load i32, ptr %t, align 4
%t_trunc2 = trunc i32 %t_loaded4 to i8
store i8 %t_trunc2, ptr %sbox_gep_j3, align 1
%sbox_i3_loaded5 = load i8, ptr %sbox_gep_i3, align 1
%sbox_i3_ext2 = zext i8 %sbox_i3_loaded5 to i32
%sbox_j3_loaded6 = load i8, ptr %sbox_gep_j3, align 1
%sbox_j3_ext = zext i8 %sbox_j3_loaded6 to i32
%sum3 = add i32 %sbox_i3_ext2, %sbox_j3_ext
%mod4 = srem i32 %sum3, 256
%sbox_gep_mod4 = getelementptr inbounds [256 x i8], ptr %sbox, i32 0, i32 %mod4
%sbox_gep_mod4_loaded = load i8, ptr %sbox_gep_mod4, align 1
%data_gep = getelementptr i8, ptr %data_ptr, i32 %k_phi3
%data_loaded = load i8, ptr %data_gep, align 1
%xor = xor i8 %data_loaded, %sbox_gep_mod4_loaded
store i8 %xor, ptr %data_gep, align 1
%next_k_phi3 = add i32 %k_phi3, 1
br label %loop_header3
loop_exit3: ; preds = %loop_header3
ret void
}
attributes #0 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
Hello, world!
MBA add
An LLVM pass that replaces add instructions with equivalent obfuscated MBA instruction sequences. These are more difficult to analyze at the expense of an increased runtime penalty.
A helper script has been implemented to support solving MBA problems. The MBA implemented in this pass has been constructed with the following parameters:
$ python3 mba_solver.py 'x+y' 32 200
TGT: x+y
MBA: 200*x + 200*y - 200*(x&y) - 198*(x|y) - 1*(x^y)
(2.04s)
Where x+y is the expression we want to obfuscate, 32 is the bitwidth and 200 is the coefficient bound.
Known limitations:
- increased code size
- increased runtime penalty
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.c -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [9 x i8] c"Sum: %d\0A\00", align 1
; Function Attrs: mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define i32 @add(i32 noundef %0, i32 noundef %1) local_unnamed_addr #0 {
%3 = add nsw i32 %1, %0
ret i32 %3
}
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #1 {
%1 = tail call i32 @add(i32 noundef 5, i32 noundef 10)
%2 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef %1)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
attributes #0 = { mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -Wall -Wextra -Wpedantic -Werror -Wno-deprecated-declarations -isystem $(llvm-config --includedir) -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="mba-add" -S test.ll -o obf.ll
MbaAddPass: Replaced 1 add operators
Check the output, note that the add instruction is replaced:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [9 x i8] c"Sum: %d\0A\00", align 1
; Function Attrs: mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define i32 @add(i32 noundef %0, i32 noundef %1) local_unnamed_addr #0 {
%term0 = mul i32 200, %1
%term1 = mul i32 200, %0
%and = and i32 %1, %0
%term2 = mul i32 200, %and
%or = or i32 %1, %0
%term3 = mul i32 198, %or
%xor = xor i32 %1, %0
%3 = add i32 %term0, %term1
%4 = sub i32 %3, %term2
%5 = sub i32 %4, %term3
%result = sub i32 %5, %xor
ret i32 %result
}
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #1 {
%1 = tail call i32 @add(i32 noundef 5, i32 noundef 10)
%2 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef %1)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
attributes #0 = { mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
Sum: 15
MBA sub
An LLVM pass that replaces sub instructions with equivalent obfuscated MBA instruction sequences. These are more difficult to analyze at the expense of an increased runtime penalty.
A helper script has been implemented to support solving MBA problems. The MBA implemented in this pass has been constructed with the following parameters:
$ python3 mba_solver.py 'x-y' 32 200
TGT: x-y
MBA: 200*x + 198*y - 200*(x&y) - 198*(x|y) - 1*(x^y)
(3.12s)
Where x-y is the expression we want to obfuscate, 32 is the bitwidth and 200 is the coefficient bound.
Known limitations:
- increased code size
- increased runtime penalty
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.c -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [12 x i8] c"Result: %d\0A\00", align 1
; Function Attrs: mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define i32 @sub(i32 noundef %0, i32 noundef %1) local_unnamed_addr #0 {
%3 = sub nsw i32 %0, %1
ret i32 %3
}
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #1 {
%1 = tail call i32 @sub(i32 noundef 5, i32 noundef 10)
%2 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef %1)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
attributes #0 = { mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -Wall -Wextra -Wpedantic -Werror -Wno-deprecated-declarations -isystem $(llvm-config --includedir) -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="mba-sub" -S test.ll -o obf.ll
MbaSubPass: Replaced 1 sub operators
Check the output, note that the sub instruction is replaced:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [12 x i8] c"Result: %d\0A\00", align 1
; Function Attrs: mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define i32 @sub(i32 noundef %0, i32 noundef %1) local_unnamed_addr #0 {
%term0 = mul i32 200, %0
%term1 = mul i32 198, %1
%and = and i32 %0, %1
%term2 = mul i32 200, %and
%or = or i32 %0, %1
%term3 = mul i32 198, %or
%xor = xor i32 %0, %1
%3 = add i32 %term0, %term1
%4 = sub i32 %3, %term2
%5 = sub i32 %4, %term3
%result = sub i32 %5, %xor
ret i32 %result
}
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #1 {
%1 = tail call i32 @sub(i32 noundef 5, i32 noundef 10)
%2 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef %1)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
attributes #0 = { mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
Result: -5
MBA const
An LLVM pass that replaces 42 constants with equivalent obfuscated MBA instruction sequences. These are more difficult to analyze at the expense of an increased runtime penalty.
A helper script has been implemented to support solving MBA problems. The MBA implemented in this pass has been constructed with the following parameters:
$ python3 mba_solver.py 42 8
TGT: 42
MBA: 20000*x + 20000*y - 20000*(x&y) - 20000*(x|y) - 214
(0.11s)
Where 42 is the constant we want to obfuscate, 8 is the bitwidth and the coefficient bound is unspecified (default: 20000).
Known limitations:
- increased code size
- increased runtime penalty
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.c -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [9 x i8] c"Sum: %d\0A\00", align 1
; Function Attrs: mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define i32 @add(i32 noundef %0, i32 noundef %1) local_unnamed_addr #0 {
%3 = add nsw i32 %1, %0
ret i32 %3
}
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #1 {
%1 = tail call i32 @add(i32 noundef 5, i32 noundef 42)
%2 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef %1)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
attributes #0 = { mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -Wall -Wextra -Wpedantic -Werror -Wno-deprecated-declarations -isystem $(llvm-config --includedir) -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="mba-const" -S test.ll -o obf.ll
MbaConstPass: Replaced 1 instance(s) of constant 42
Check the output, note that the 42 constant is replaced:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [9 x i8] c"Sum: %d\0A\00", align 1
@x = internal constant i32 13
@y = internal constant i32 21
; Function Attrs: mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define i32 @add(i32 noundef %0, i32 noundef %1) local_unnamed_addr #0 {
%3 = add nsw i32 %1, %0
ret i32 %3
}
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #1 {
%x = load i32, ptr @x, align 4
%y = load i32, ptr @y, align 4
%term0 = mul i32 20000, %x
%term1 = mul i32 20000, %y
%and = and i32 %x, %y
%term2 = mul i32 20000, %and
%or = or i32 %x, %y
%term3 = mul i32 20000, %or
%sum1 = add i32 %term0, %term1
%sum2 = sub i32 %sum1, %term2
%sum3 = sub i32 %sum2, %term3
%result = sub i32 %sum3, 214
%trunc8 = trunc i32 %result to i8
%zext32 = zext i8 %trunc8 to i32
%1 = tail call i32 @add(i32 noundef 5, i32 noundef %zext32)
%2 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef %1)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
attributes #0 = { mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.2"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
Sum: 47
ptrace deny
An LLVM pass that inserts a ptrace(PT_DENY_ATTACH, 0, 0, 0); call (which tells the kernel to not allow any debugger to attach to this process) into either all functions (-passes="ptrace-deny") or only the specified ones (-passes="ptrace-deny<main>"). Refer to the ptrace documentation for more information.
Known limitations:
- increased code size (negligible)
- increased runtime penalty (negligible)
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.m -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.m'
source_filename = "test.m"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
%struct.__NSConstantString_tag = type { ptr, i32, ptr, i64 }
@__CFConstantStringClassReference = external global [0 x i32]
@.str = private unnamed_addr constant [14 x i8] c"Hello, World!\00", section "__TEXT,__cstring,cstring_literals", align 1
@_unnamed_cfstring_ = private global %struct.__NSConstantString_tag { ptr @__CFConstantStringClassReference, i32 1992, ptr @.str, i64 13 }, section "__DATA,__cfstring", align 8 #0
; Function Attrs: ssp uwtable(sync)
define noundef i32 @main(i32 noundef %0, ptr noundef readnone captures(none) %1) local_unnamed_addr #1 {
%3 = tail call ptr @llvm.objc.autoreleasePoolPush() #2
notail call void (ptr, ...) @NSLog(ptr noundef nonnull @_unnamed_cfstring_)
tail call void @llvm.objc.autoreleasePoolPop(ptr %3)
ret i32 0
}
; Function Attrs: nounwind
declare ptr @llvm.objc.autoreleasePoolPush() #2
declare void @NSLog(ptr noundef, ...) local_unnamed_addr #3
; Function Attrs: nounwind
declare void @llvm.objc.autoreleasePoolPop(ptr) #2
attributes #0 = { "objc_arc_inert" }
attributes #1 = { ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nounwind }
attributes #3 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7, !8, !9, !10}
!llvm.ident = !{!11}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
!2 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!3 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!4 = !{i32 1, !"Objective-C Garbage Collection", i8 0}
!5 = !{i32 1, !"Objective-C Class Properties", i32 64}
!6 = !{i32 1, !"Objective-C Enforce ClassRO Pointer Signing", i8 0}
!7 = !{i32 1, !"wchar_size", i32 4}
!8 = !{i32 8, !"PIC Level", i32 2}
!9 = !{i32 7, !"uwtable", i32 1}
!10 = !{i32 7, !"frame-pointer", i32 1}
!11 = !{!"Homebrew clang version 21.1.3"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -Wall -Wextra -Wpedantic -Werror -Wno-deprecated-declarations -isystem $(llvm-config --includedir) -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="ptrace-deny" -S test.ll -o obf.ll
PtraceDenyPass: Injected ptrace into function 'main'
Check the output, note that the ptrace() function call has been added:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.m"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
%struct.__NSConstantString_tag = type { ptr, i32, ptr, i64 }
@__CFConstantStringClassReference = external global [0 x i32]
@.str = private unnamed_addr constant [14 x i8] c"Hello, World!\00", section "__TEXT,__cstring,cstring_literals", align 1
@_unnamed_cfstring_ = private global %struct.__NSConstantString_tag { ptr @__CFConstantStringClassReference, i32 1992, ptr @.str, i64 13 }, section "__DATA,__cfstring", align 8 #0
; Function Attrs: ssp uwtable(sync)
define noundef i32 @main(i32 noundef %0, ptr noundef readnone captures(none) %1) local_unnamed_addr #1 {
%3 = call i32 @ptrace(i32 31, i32 0, ptr null, i32 0)
%4 = tail call ptr @llvm.objc.autoreleasePoolPush() #2
notail call void (ptr, ...) @NSLog(ptr noundef nonnull @_unnamed_cfstring_)
tail call void @llvm.objc.autoreleasePoolPop(ptr %4)
ret i32 0
}
; Function Attrs: nounwind
declare ptr @llvm.objc.autoreleasePoolPush() #2
declare void @NSLog(ptr noundef, ...) local_unnamed_addr #3
; Function Attrs: nounwind
declare void @llvm.objc.autoreleasePoolPop(ptr) #2
declare i32 @ptrace(i32, i32, ptr, i32)
attributes #0 = { "objc_arc_inert" }
attributes #1 = { ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nounwind }
attributes #3 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7, !8, !9, !10}
!llvm.ident = !{!11}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
!2 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!3 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!4 = !{i32 1, !"Objective-C Garbage Collection", i8 0}
!5 = !{i32 1, !"Objective-C Class Properties", i32 64}
!6 = !{i32 1, !"Objective-C Enforce ClassRO Pointer Signing", i8 0}
!7 = !{i32 1, !"wchar_size", i32 4}
!8 = !{i32 8, !"PIC Level", i32 2}
!9 = !{i32 7, !"uwtable", i32 1}
!10 = !{i32 7, !"frame-pointer", i32 1}
!11 = !{!"Homebrew clang version 21.1.3"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang -framework Foundation obf.ll -o obf && ./obf
2025-10-12 14:52:59.754 obf[11116:354178] Hello, World!
Run the executables with a debugger:
$ clang test.m -O3 -framework Foundation -o test
$ lldb ./test -o "run" -o "exit"
(lldb) target create "./test"
Current executable set to '/Users/gemesa/git-repos/phantom-pass/src/8-ptrace-deny/test' (arm64).
(lldb) run
2025-10-12 17:45:21.800577+0200 test[13460:500271] Hello, World!
Process 13460 launched: '/Users/gemesa/git-repos/phantom-pass/src/8-ptrace-deny/test' (arm64)
Process 13460 exited with status = 0 (0x00000000)
(lldb) exit
$ lldb ./obf -o "run" -o "exit"
(lldb) target create "./obf"
Current executable set to '/Users/gemesa/git-repos/phantom-pass/src/8-ptrace-deny/obf' (arm64).
(lldb) run
Process 11726 launched: '/Users/gemesa/git-repos/phantom-pass/src/8-ptrace-deny/obf' (arm64)
Process 11726 exited with status = 45 (0x0000002d)
(lldb) exit
ptrace deny (inline asm)
An LLVM pass that inserts an inline asm instruction sequence equivalent to a ptrace(PT_DENY_ATTACH, 0, 0, 0); call (which tells the kernel to not allow any debugger to attach to this process) into either all functions (-passes="ptrace-deny") or only the specified ones (-passes="ptrace-deny<main>"). Refer to the ptrace documentation for more information.
Known limitations:
- increased code size (negligible)
- increased runtime penalty (negligible)
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.m -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.m'
source_filename = "test.m"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
%struct.__NSConstantString_tag = type { ptr, i32, ptr, i64 }
@__CFConstantStringClassReference = external global [0 x i32]
@.str = private unnamed_addr constant [14 x i8] c"Hello, World!\00", section "__TEXT,__cstring,cstring_literals", align 1
@_unnamed_cfstring_ = private global %struct.__NSConstantString_tag { ptr @__CFConstantStringClassReference, i32 1992, ptr @.str, i64 13 }, section "__DATA,__cfstring", align 8 #0
; Function Attrs: ssp uwtable(sync)
define noundef i32 @main(i32 noundef %0, ptr noundef readnone captures(none) %1) local_unnamed_addr #1 {
%3 = tail call ptr @llvm.objc.autoreleasePoolPush() #2
notail call void (ptr, ...) @NSLog(ptr noundef nonnull @_unnamed_cfstring_)
tail call void @llvm.objc.autoreleasePoolPop(ptr %3)
ret i32 0
}
; Function Attrs: nounwind
declare ptr @llvm.objc.autoreleasePoolPush() #2
declare void @NSLog(ptr noundef, ...) local_unnamed_addr #3
; Function Attrs: nounwind
declare void @llvm.objc.autoreleasePoolPop(ptr) #2
attributes #0 = { "objc_arc_inert" }
attributes #1 = { ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nounwind }
attributes #3 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7, !8, !9, !10}
!llvm.ident = !{!11}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
!2 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!3 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!4 = !{i32 1, !"Objective-C Garbage Collection", i8 0}
!5 = !{i32 1, !"Objective-C Class Properties", i32 64}
!6 = !{i32 1, !"Objective-C Enforce ClassRO Pointer Signing", i8 0}
!7 = !{i32 1, !"wchar_size", i32 4}
!8 = !{i32 8, !"PIC Level", i32 2}
!9 = !{i32 7, !"uwtable", i32 1}
!10 = !{i32 7, !"frame-pointer", i32 1}
!11 = !{!"Homebrew clang version 21.1.3"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -Wall -Wextra -Wpedantic -Werror -Wno-deprecated-declarations -isystem $(llvm-config --includedir) -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="ptrace-deny" -S test.ll -o obf.ll
PtraceDenyPass: Injected ptrace into function 'main'
Check the output, note that the inline asm block has been added. In this case, it makes more sense to look at the generated asm:
$ clang -S obf.ll -o obf.s
$ cat obf.s
.build_version macos, 15, 0 sdk_version 15, 5
.section __TEXT,__text,regular,pure_instructions
.globl _main ; -- Begin function main
.p2align 2
_main: ; @main
.cfi_startproc
; %bb.0:
sub sp, sp, #32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
add x29, sp, #16
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
; InlineAsm Start
stp x0, x1, [sp, #-16]!
stp x2, x3, [sp, #-16]!
mov x0, #31 ; =0x1f
mov x1, #0 ; =0x0
mov x2, #0 ; =0x0
mov x3, #0 ; =0x0
mov x16, #26 ; =0x1a
svc #0x80
ldp x2, x3, [sp], #16
ldp x0, x1, [sp], #16
; InlineAsm End
bl _objc_autoreleasePoolPush
str x0, [sp, #8] ; 8-byte Folded Spill
adrp x0, l__unnamed_cfstring_@PAGE
add x0, x0, l__unnamed_cfstring_@PAGEOFF
bl _NSLog
ldr x0, [sp, #8] ; 8-byte Folded Reload
bl _objc_autoreleasePoolPop
mov w0, #0 ; =0x0
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32
ret
.cfi_endproc
; -- End function
.section __TEXT,__cstring,cstring_literals
l_.str: ; @.str
.asciz "Hello, World!"
.section __DATA,__cfstring
.p2align 3, 0x0 ; @_unnamed_cfstring_
l__unnamed_cfstring_:
.quad ___CFConstantStringClassReference
.long 1992 ; 0x7c8
.space 4
.quad l_.str
.quad 13 ; 0xd
.section __DATA,__objc_imageinfo,regular,no_dead_strip
L_OBJC_IMAGE_INFO:
.long 0
.long 64
.subsections_via_symbols
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang -framework Foundation obf.ll -o obf && ./obf
2025-10-12 18:03:12.521 obf[14223:518045] Hello, World!
Run the executables with a debugger:
$ clang test.m -O3 -framework Foundation -o test
$ lldb ./test -o "run" -o "exit"
(lldb) target create "./test"
Current executable set to '/Users/gemesa/git-repos/phantom-pass/src/9-ptrace-deny-asm/test' (arm64).
(lldb) run
2025-10-12 18:04:01.383159+0200 test[14247:518774] Hello, World!
Process 14247 launched: '/Users/gemesa/git-repos/phantom-pass/src/9-ptrace-deny-asm/test' (arm64)
Process 14247 exited with status = 0 (0x00000000)
(lldb) exit
$ lldb ./obf -o "run" -o "exit"
(lldb) target create "./obf"
Current executable set to '/Users/gemesa/git-repos/phantom-pass/src/9-ptrace-deny-asm/obf' (arm64).
(lldb) run
Process 14254 launched: '/Users/gemesa/git-repos/phantom-pass/src/9-ptrace-deny-asm/obf' (arm64)
Process 14254 exited with status = 45 (0x0000002d)
(lldb) exit
Frida deny (basic)
An LLVM pass that inserts an inline asm instruction sequence into (mov x16, x16\nmov x17, x17) the prologue of either all functions (-passes="frida-deny") or only the specified ones (-passes="frida-deny<main>"). On AArch64, Frida uses x16 and x17 internally (for an example, see last_stack_push in the docs). For this reason, it checks if these registers are being used in the function prologue and fails to hook in this case.
Known limitations:
- increased code size (negligible)
- increased runtime penalty (negligible)
- can be patched easily
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.m -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.m'
source_filename = "test.m"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
%struct.__NSConstantString_tag = type { ptr, i32, ptr, i64 }
@__CFConstantStringClassReference = external global [0 x i32]
@.str = private unnamed_addr constant [14 x i8] c"Hello, World!\00", section "__TEXT,__cstring,cstring_literals", align 1
@_unnamed_cfstring_ = private global %struct.__NSConstantString_tag { ptr @__CFConstantStringClassReference, i32 1992, ptr @.str, i64 13 }, section "__DATA,__cfstring", align 8 #0
; Function Attrs: ssp uwtable(sync)
define noundef i32 @main(i32 noundef %0, ptr noundef readnone captures(none) %1) local_unnamed_addr #1 {
%3 = tail call ptr @llvm.objc.autoreleasePoolPush() #2
notail call void (ptr, ...) @NSLog(ptr noundef nonnull @_unnamed_cfstring_)
tail call void @llvm.objc.autoreleasePoolPop(ptr %3)
ret i32 0
}
; Function Attrs: nounwind
declare ptr @llvm.objc.autoreleasePoolPush() #2
declare void @NSLog(ptr noundef, ...) local_unnamed_addr #3
; Function Attrs: nounwind
declare void @llvm.objc.autoreleasePoolPop(ptr) #2
attributes #0 = { "objc_arc_inert" }
attributes #1 = { ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nounwind }
attributes #3 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7, !8, !9, !10}
!llvm.ident = !{!11}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
!2 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!3 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!4 = !{i32 1, !"Objective-C Garbage Collection", i8 0}
!5 = !{i32 1, !"Objective-C Class Properties", i32 64}
!6 = !{i32 1, !"Objective-C Enforce ClassRO Pointer Signing", i8 0}
!7 = !{i32 1, !"wchar_size", i32 4}
!8 = !{i32 8, !"PIC Level", i32 2}
!9 = !{i32 7, !"uwtable", i32 1}
!10 = !{i32 7, !"frame-pointer", i32 1}
!11 = !{!"Homebrew clang version 21.1.3"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -Wall -Wextra -Wpedantic -Werror -Wno-deprecated-declarations -isystem $(llvm-config --includedir) -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="frida-deny" -S test.ll -o obf.ll
FridaDenyPass: Injected frida deny prologue into function 'main'
Check the output, note that the raw bytes have been added. In this case, it makes more sense to look at the generated asm:
$ clang -S obf.ll -o obf.s
$ cat obf.s
.build_version macos, 15, 0 sdk_version 15, 5
.section __TEXT,__text,regular,pure_instructions
.globl _main ; -- Begin function main
.p2align 2
_main: ; @main
.cfi_startproc
.byte 240 ; 0xf0
.byte 3 ; 0x3
.byte 16 ; 0x10
.byte 170 ; 0xaa
.byte 241 ; 0xf1
.byte 3 ; 0x3
.byte 17 ; 0x11
.byte 170 ; 0xaa
; %bb.0:
sub sp, sp, #32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
add x29, sp, #16
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
bl _objc_autoreleasePoolPush
str x0, [sp, #8] ; 8-byte Folded Spill
adrp x0, l__unnamed_cfstring_@PAGE
add x0, x0, l__unnamed_cfstring_@PAGEOFF
bl _NSLog
ldr x0, [sp, #8] ; 8-byte Folded Reload
bl _objc_autoreleasePoolPop
mov w0, #0 ; =0x0
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32
ret
.cfi_endproc
; -- End function
.section __TEXT,__cstring,cstring_literals
l_.str: ; @.str
.asciz "Hello, World!"
.section __DATA,__cfstring
.p2align 3, 0x0 ; @_unnamed_cfstring_
l__unnamed_cfstring_:
.quad ___CFConstantStringClassReference
.long 1992 ; 0x7c8
.space 4
.quad l_.str
.quad 13 ; 0xd
.section __DATA,__objc_imageinfo,regular,no_dead_strip
L_OBJC_IMAGE_INFO:
.long 0
.long 64
.subsections_via_symbols
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang -framework Foundation obf.ll -o obf && ./obf
2025-10-16 12:51:05.959 obf[7264:276542] Hello, World!
Run the executables with Frida:
$ clang test.m -O3 -framework Foundation -o test
$ frida-trace -f test -i main
Instrumenting...
main: Loaded handler at "/Users/gemesa/git-repos/phantom-pass/src/10-frida-deny-basic/__handlers__/test/main.js"
Started tracing 1 function. Web UI available at http://localhost:50206/
2025-10-16 12:51:52.000 test[7280:277197] Hello, World!
/* TID 0x103 */
14 ms main()
Process terminated
$ frida-trace -f obf -i main
Instrumenting...
main: Loaded handler at "/Users/gemesa/git-repos/phantom-pass/src/10-frida-deny-basic/__handlers__/obf/main.js"
Warning: Skipping "main": unable to intercept function at 0x104a08610; please file a bug
Started tracing 1 function. Web UI available at http://localhost:50210/
2025-10-16 12:51:57.320 obf[7290:277334] Hello, World!
Process terminated
If we disassemble main, we can see the instruction opcodes and operands instead of the raw bytes:
$ llvm-objdump --disassemble-symbols=_main obf
obf: file format mach-o arm64
Disassembly of section __TEXT,__text:
0000000100000610 <_main>:
100000610: aa1003f0 mov x16, x16
100000614: aa1103f1 mov x17, x17
100000618: d10083ff sub sp, sp, #0x20
10000061c: a9017bfd stp x29, x30, [sp, #0x10]
100000620: 910043fd add x29, sp, #0x10
100000624: 9400000f bl 0x100000660 <_objc_autoreleasePoolPush+0x100000660>
100000628: f90007e0 str x0, [sp, #0x8]
10000062c: 90000020 adrp x0, 0x100004000 <_objc_autoreleasePoolPush+0x100004000>
100000630: 91006000 add x0, x0, #0x18
100000634: 94000005 bl 0x100000648 <_objc_autoreleasePoolPush+0x100000648>
100000638: f94007e0 ldr x0, [sp, #0x8]
10000063c: 94000006 bl 0x100000654 <_objc_autoreleasePoolPush+0x100000654>
100000640: 14000001 b 0x100000644 <_main+0x34>
100000644: 14000000 b 0x100000644 <_main+0x34>
Frida deny (complex)
Note: The outcome of this pass is the same as Frida deny (basic). The difference is only in the implementation. This pass uses an assembler that allows the developer to work with mnemonics (e.g.
mov x16, x16) instead of raw bytes (e.g.0xF0, 0x03, 0x10, 0xAA). This pass also appends the prologue even if another one is provided (e.g. by another pass).
An LLVM pass that inserts an inline assembly instruction sequence (mov x16, x16\nmov x17, x17) into the prologue of either all functions (-passes="frida-deny") or only the specified ones (-passes="frida-deny<main>"). On AArch64, Frida uses x16 and x17 internally (for an example, see last_stack_push in the docs). For this reason, it checks if these registers are being used in the function prologue and fails to hook in this case.
Known limitations:
- increased code size (negligible)
- increased runtime penalty (negligible)
- can be patched easily
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.m -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.m'
source_filename = "test.m"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
%struct.__NSConstantString_tag = type { ptr, i32, ptr, i64 }
@__CFConstantStringClassReference = external global [0 x i32]
@.str = private unnamed_addr constant [14 x i8] c"Hello, World!\00", section "__TEXT,__cstring,cstring_literals", align 1
@_unnamed_cfstring_ = private global %struct.__NSConstantString_tag { ptr @__CFConstantStringClassReference, i32 1992, ptr @.str, i64 13 }, section "__DATA,__cfstring", align 8 #0
; Function Attrs: ssp uwtable(sync)
define noundef i32 @main(i32 noundef %0, ptr noundef readnone captures(none) %1) local_unnamed_addr #1 {
%3 = tail call ptr @llvm.objc.autoreleasePoolPush() #2
notail call void (ptr, ...) @NSLog(ptr noundef nonnull @_unnamed_cfstring_)
tail call void @llvm.objc.autoreleasePoolPop(ptr %3)
ret i32 0
}
; Function Attrs: nounwind
declare ptr @llvm.objc.autoreleasePoolPush() #2
declare void @NSLog(ptr noundef, ...) local_unnamed_addr #3
; Function Attrs: nounwind
declare void @llvm.objc.autoreleasePoolPop(ptr) #2
attributes #0 = { "objc_arc_inert" }
attributes #1 = { ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nounwind }
attributes #3 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7, !8, !9, !10}
!llvm.ident = !{!11}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
!2 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!3 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!4 = !{i32 1, !"Objective-C Garbage Collection", i8 0}
!5 = !{i32 1, !"Objective-C Class Properties", i32 64}
!6 = !{i32 1, !"Objective-C Enforce ClassRO Pointer Signing", i8 0}
!7 = !{i32 1, !"wchar_size", i32 4}
!8 = !{i32 8, !"PIC Level", i32 2}
!9 = !{i32 7, !"uwtable", i32 1}
!10 = !{i32 7, !"frame-pointer", i32 1}
!11 = !{!"Homebrew clang version 21.1.3"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -Wall -Wextra -Wpedantic -Werror -Wno-deprecated-declarations -isystem $(llvm-config --includedir) -I ../util -shared -fPIC $(llvm-config --cxxflags) ../util/assembler.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o asm.o
$ clang++ -std=c++17 -O3 -Wall -Wextra -Wpedantic -Werror -Wno-deprecated-declarations -isystem $(llvm-config --includedir) -I ../util -shared -fPIC $(llvm-config --cxxflags) ../util/disassembler.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o disasm.o
$ clang++ -std=c++17 -O3 -Wall -Wextra -Wpedantic -Werror -Wno-deprecated-declarations -isystem $(llvm-config --includedir) -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) asm.o disasm.o -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="frida-deny" -S test.ll -o obf.ll
FridaDenyPass: Injected frida deny prologue into function 'main'
Check the output, note that the raw bytes have been added. In this case, it makes more sense to look at the generated asm:
$ clang -S obf.ll -o obf.s
$ cat obf.s
.build_version macos, 15, 0 sdk_version 15, 5
.section __TEXT,__text,regular,pure_instructions
.globl _main ; -- Begin function main
.p2align 2
_main: ; @main
.cfi_startproc
.byte 240 ; 0xf0
.byte 3 ; 0x3
.byte 16 ; 0x10
.byte 170 ; 0xaa
.byte 241 ; 0xf1
.byte 3 ; 0x3
.byte 17 ; 0x11
.byte 170 ; 0xaa
; %bb.0:
sub sp, sp, #32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
add x29, sp, #16
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
bl _objc_autoreleasePoolPush
str x0, [sp, #8] ; 8-byte Folded Spill
adrp x0, l__unnamed_cfstring_@PAGE
add x0, x0, l__unnamed_cfstring_@PAGEOFF
bl _NSLog
ldr x0, [sp, #8] ; 8-byte Folded Reload
bl _objc_autoreleasePoolPop
mov w0, #0 ; =0x0
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32
ret
.cfi_endproc
; -- End function
.section __TEXT,__cstring,cstring_literals
l_.str: ; @.str
.asciz "Hello, World!"
.section __DATA,__cfstring
.p2align 3, 0x0 ; @_unnamed_cfstring_
l__unnamed_cfstring_:
.quad ___CFConstantStringClassReference
.long 1992 ; 0x7c8
.space 4
.quad l_.str
.quad 13 ; 0xd
.section __DATA,__objc_imageinfo,regular,no_dead_strip
L_OBJC_IMAGE_INFO:
.long 0
.long 64
.subsections_via_symbols
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang -framework Foundation obf.ll -o obf && ./obf
2025-10-16 14:02:24.947 obf[10403:356883] Hello, World!
Run the executables with Frida:
$ clang test.m -O3 -framework Foundation -o test
$ frida-trace -f test -i main
Instrumenting...
main: Auto-generated handler at "/Users/gemesa/git-repos/phantom-pass/src/11-frida-deny-complex/__handlers__/test/main.js"
Started tracing 1 function. Web UI available at http://localhost:50613/
2025-10-16 14:01:45.840 test[10360:356143] Hello, World!
/* TID 0x103 */
182 ms main()
Process terminated
$ frida-trace -f obf -i main
Instrumenting...
main: Loaded handler at "/Users/gemesa/git-repos/phantom-pass/src/11-frida-deny-complex/__handlers__/obf/main.js"
Warning: Skipping "main": unable to intercept function at 0x10406c610; please file a bug
Started tracing 1 function. Web UI available at http://localhost:50617/
2025-10-16 14:01:50.438 obf[10376:356332] Hello, World!
Process terminated
If we disassemble main, we can see the instruction opcodes and operands instead of the raw bytes:
$ llvm-objdump --disassemble-symbols=_main obf
obf: file format mach-o arm64
Disassembly of section __TEXT,__text:
0000000100000610 <_main>:
100000610: aa1003f0 mov x16, x16
100000614: aa1103f1 mov x17, x17
100000618: d10083ff sub sp, sp, #0x20
10000061c: a9017bfd stp x29, x30, [sp, #0x10]
100000620: 910043fd add x29, sp, #0x10
100000624: 9400000f bl 0x100000660 <_objc_autoreleasePoolPush+0x100000660>
100000628: f90007e0 str x0, [sp, #0x8]
10000062c: 90000020 adrp x0, 0x100004000 <_objc_autoreleasePoolPush+0x100004000>
100000630: 91006000 add x0, x0, #0x18
100000634: 94000005 bl 0x100000648 <_objc_autoreleasePoolPush+0x100000648>
100000638: f94007e0 ldr x0, [sp, #0x8]
10000063c: 94000006 bl 0x100000654 <_objc_autoreleasePoolPush+0x100000654>
100000640: 14000001 b 0x100000644 <_main+0x34>
100000644: 14000000 b 0x100000644 <_main+0x34>
Frida deny with runtime check
Note: This is an extension of Frida deny (basic). Since the inserted instructions (e.g.
mov x16, x16) can be easily patched, this pass adds a runtime integrity check for each protected function that detects tampering with the function prologue and terminates execution.
Known limitations:
- increased code size (small)
- increased runtime penalty (small)
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.m -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.m'
source_filename = "test.m"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
%struct.__NSConstantString_tag = type { ptr, i32, ptr, i64 }
@__CFConstantStringClassReference = external global [0 x i32]
@.str = private unnamed_addr constant [7 x i8] c"secret\00", section "__TEXT,__cstring,cstring_literals", align 1
@_unnamed_cfstring_ = private global %struct.__NSConstantString_tag { ptr @__CFConstantStringClassReference, i32 1992, ptr @.str, i64 6 }, section "__DATA,__cfstring", align 8 #0
@.str.1 = private unnamed_addr constant [14 x i8] c"Hello, World!\00", section "__TEXT,__cstring,cstring_literals", align 1
@_unnamed_cfstring_.2 = private global %struct.__NSConstantString_tag { ptr @__CFConstantStringClassReference, i32 1992, ptr @.str.1, i64 13 }, section "__DATA,__cfstring", align 8 #0
; Function Attrs: ssp uwtable(sync)
define void @secret() local_unnamed_addr #1 {
notail call void (ptr, ...) @NSLog(ptr noundef nonnull @_unnamed_cfstring_)
ret void
}
declare void @NSLog(ptr noundef, ...) local_unnamed_addr #2
; Function Attrs: ssp uwtable(sync)
define noundef i32 @main(i32 noundef %0, ptr noundef readnone captures(none) %1) local_unnamed_addr #1 {
%3 = tail call ptr @llvm.objc.autoreleasePoolPush() #3
notail call void (ptr, ...) @NSLog(ptr noundef nonnull @_unnamed_cfstring_.2)
notail call void (ptr, ...) @NSLog(ptr noundef nonnull @_unnamed_cfstring_)
tail call void @llvm.objc.autoreleasePoolPop(ptr %3)
ret i32 0
}
; Function Attrs: nounwind
declare ptr @llvm.objc.autoreleasePoolPush() #3
; Function Attrs: nounwind
declare void @llvm.objc.autoreleasePoolPop(ptr) #3
attributes #0 = { "objc_arc_inert" }
attributes #1 = { ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #3 = { nounwind }
!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7, !8, !9, !10}
!llvm.ident = !{!11}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
!2 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!3 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!4 = !{i32 1, !"Objective-C Garbage Collection", i8 0}
!5 = !{i32 1, !"Objective-C Class Properties", i32 64}
!6 = !{i32 1, !"Objective-C Enforce ClassRO Pointer Signing", i8 0}
!7 = !{i32 1, !"wchar_size", i32 4}
!8 = !{i32 8, !"PIC Level", i32 2}
!9 = !{i32 7, !"uwtable", i32 1}
!10 = !{i32 7, !"frame-pointer", i32 1}
!11 = !{!"Homebrew clang version 21.1.3"}
Build the pass plugin:
$ clang++ -std=c++17 -O3 -Wall -Wextra -Wpedantic -Werror -Wno-deprecated-declarations -isystem $(llvm-config --includedir) -shared -fPIC $(llvm-config --cxxflags) obf.cpp $(llvm-config --ldflags --libs core support passes analysis transformutils target bitwriter) -o obf.dylib
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="frida-deny-check<secret>" -S test.ll -o obf.ll
FridaDenyPass: Injected frida deny prologue into function 'secret'
+ Created checker function: __check_secret()
FridaDenyPass: Injected 1 checker call(s) into main()
Check the output, note that the raw bytes (mov x16, x16\nmov x17, x17) and the checker function (__check_secret) have been added. In this case, it makes more sense to look at the generated asm:
$ clang -S obf.ll -o obf.s
$ cat obf.s
.build_version macos, 15, 0 sdk_version 15, 5
.section __TEXT,__text,regular,pure_instructions
.globl _secret ; -- Begin function secret
.p2align 2
_secret: ; @secret
.cfi_startproc
.byte 240 ; 0xf0
.byte 3 ; 0x3
.byte 16 ; 0x10
.byte 170 ; 0xaa
.byte 241 ; 0xf1
.byte 3 ; 0x3
.byte 17 ; 0x11
.byte 170 ; 0xaa
; %bb.0:
stp x29, x30, [sp, #-16]! ; 16-byte Folded Spill
mov x29, sp
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
adrp x0, l__unnamed_cfstring_@PAGE
add x0, x0, l__unnamed_cfstring_@PAGEOFF
bl _NSLog
ldp x29, x30, [sp], #16 ; 16-byte Folded Reload
ret
.cfi_endproc
; -- End function
.globl _main ; -- Begin function main
.p2align 2
_main: ; @main
.cfi_startproc
; %bb.0:
sub sp, sp, #32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
add x29, sp, #16
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
bl ___check_secret
bl _objc_autoreleasePoolPush
str x0, [sp, #8] ; 8-byte Folded Spill
adrp x0, l__unnamed_cfstring_.2@PAGE
add x0, x0, l__unnamed_cfstring_.2@PAGEOFF
bl _NSLog
adrp x0, l__unnamed_cfstring_@PAGE
add x0, x0, l__unnamed_cfstring_@PAGEOFF
bl _NSLog
ldr x0, [sp, #8] ; 8-byte Folded Reload
bl _objc_autoreleasePoolPop
mov w0, #0 ; =0x0
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32
ret
.cfi_endproc
; -- End function
.globl ___check_secret ; -- Begin function __check_secret
.p2align 2
___check_secret: ; @__check_secret
.cfi_startproc
; %bb.0: ; %entry
stp x29, x30, [sp, #-16]! ; 16-byte Folded Spill
.cfi_def_cfa_offset 16
.cfi_offset w30, -8
.cfi_offset w29, -16
adrp x0, _secret@PAGE
add x0, x0, _secret@PAGEOFF
adrp x1, l_.expected_prologue_secret@PAGE
add x1, x1, l_.expected_prologue_secret@PAGEOFF
mov w8, #8 ; =0x8
mov x2, x8
bl _memcmp
cbnz w0, LBB2_2
b LBB2_1
LBB2_1: ; %success
ldp x29, x30, [sp], #16 ; 16-byte Folded Reload
ret
LBB2_2: ; %fail
adrp x0, l_.str.2@PAGE
add x0, x0, l_.str.2@PAGEOFF
bl _printf
mov w0, #1 ; =0x1
bl _exit
brk #0x1
.cfi_endproc
; -- End function
.section __TEXT,__cstring,cstring_literals
l_.str: ; @.str
.asciz "secret"
.section __DATA,__cfstring
.p2align 3, 0x0 ; @_unnamed_cfstring_
l__unnamed_cfstring_:
.quad ___CFConstantStringClassReference
.long 1992 ; 0x7c8
.space 4
.quad l_.str
.quad 6 ; 0x6
.section __TEXT,__cstring,cstring_literals
l_.str.1: ; @.str.1
.asciz "Hello, World!"
.section __DATA,__cfstring
.p2align 3, 0x0 ; @_unnamed_cfstring_.2
l__unnamed_cfstring_.2:
.quad ___CFConstantStringClassReference
.long 1992 ; 0x7c8
.space 4
.quad l_.str.1
.quad 13 ; 0xd
.section __TEXT,__const
l_.expected_prologue_secret: ; @.expected_prologue_secret
.ascii "\360\003\020\252\361\003\021\252"
.p2align 4, 0x0 ; @.str.2
l_.str.2:
.asciz "\nPatching/hooking detected.\nPrologue of function 'secret' has been modified.\nExiting...\n\n"
.section __DATA,__objc_imageinfo,regular,no_dead_strip
L_OBJC_IMAGE_INFO:
.long 0
.long 64
.subsections_via_symbols
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang -framework Foundation obf.ll -o obf && ./obf
2025-10-17 11:53:56.880 obf[13584:248061] Hello, World!
2025-10-17 11:53:56.881 obf[13584:248061] secret
Run the executables with Frida:
$ clang test.m -O3 -framework Foundation -o test
$ frida-trace -f test -i secret
Instrumenting...
secret: Loaded handler at "/Users/gemesa/git-repos/phantom-pass/src/12-frida-deny-with-runtime-check/__handlers__/test/secret.js"
Started tracing 1 function. Web UI available at http://localhost:50759/
2025-10-17 11:54:54.855 test[13624:249239] Hello, World!
2025-10-17 11:54:54.855 test[13624:249239] secret
Process terminated
$ frida-trace -f obf -i secret
Instrumenting...
secret: Loaded handler at "/Users/gemesa/git-repos/phantom-pass/src/12-frida-deny-with-runtime-check/__handlers__/obf/secret.js"
Warning: Skipping "secret": unable to intercept function at 0x10017c6b0; please file a bug
Started tracing 1 function. Web UI available at http://localhost:50753/
2025-10-17 11:54:21.163 obf[13596:248577] Hello, World!
2025-10-17 11:54:21.164 obf[13596:248577] secret
Process terminated
If we disassemble secret, we can see the instruction opcodes and operands instead of the raw bytes:
$ llvm-objdump --disassemble-symbols=_secret obf
obf: file format mach-o arm64
Disassembly of section __TEXT,__text:
00000001000006b0 <_secret>:
1000006b0: aa1003f0 mov x16, x16
1000006b4: aa1103f1 mov x17, x17
1000006b8: a9bf7bfd stp x29, x30, [sp, #-0x10]!
1000006bc: 910003fd mov x29, sp
1000006c0: 90000020 adrp x0, 0x100004000 <_printf+0x100004000>
1000006c4: 9100c000 add x0, x0, #0x30
1000006c8: 94000027 bl 0x100000764 <_printf+0x100000764>
1000006cc: a8c17bfd ldp x29, x30, [sp], #0x10
1000006d0: d65f03c0 ret
If we patch the binary, the runtime check detects it and aborts:
$ llvm-objdump --disassemble-symbols=_secret obf-patch
obf-patch: file format mach-o arm64
Disassembly of section __TEXT,__text:
00000001000006b0 <_secret>:
1000006b0: d503201f nop
1000006b4: aa1103f1 mov x17, x17
1000006b8: a9bf7bfd stp x29, x30, [sp, #-0x10]!
1000006bc: 910003fd mov x29, sp
1000006c0: 90000020 adrp x0, 0x100004000 <_printf+0x100004000>
1000006c4: 9100c000 add x0, x0, #0x30
1000006c8: 94000025 bl 0x10000075c <_printf+0x10000075c>
1000006cc: a8c17bfd ldp x29, x30, [sp], #0x10
1000006d0: d65f03c0 ret
After patching, the binary must be re-signed:
$ codesign -s - obf-patch
Then, it can be executed:
$ ./obf-patch
Patching/hooking detected.
Prologue of function 'secret' has been modified.
Exiting...
sysctl debugger check
An LLVM pass that inserts the sysctl based debugger detection check recommended by Apple into either all functions (-passes="sysctl-debugger-check") or only the specified ones (-passes="sysctl-debugger-check<main>"). If a debugger is detected, the program exits with status 1.
Known limitations:
- increased code size (negligible)
- increased runtime penalty (negligible)
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.m -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.m'
source_filename = "test.m"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
%struct.__NSConstantString_tag = type { ptr, i32, ptr, i64 }
@__CFConstantStringClassReference = external global [0 x i32]
@.str = private unnamed_addr constant [14 x i8] c"Hello, World!\00", section "__TEXT,__cstring,cstring_literals", align 1
@_unnamed_cfstring_ = private global %struct.__NSConstantString_tag { ptr @__CFConstantStringClassReference, i32 1992, ptr @.str, i64 13 }, section "__DATA,__cfstring", align 8 #0
; Function Attrs: ssp uwtable(sync)
define noundef i32 @main(i32 noundef %0, ptr noundef readnone captures(none) %1) local_unnamed_addr #1 {
%3 = tail call ptr @llvm.objc.autoreleasePoolPush() #2
notail call void (ptr, ...) @NSLog(ptr noundef nonnull @_unnamed_cfstring_)
tail call void @llvm.objc.autoreleasePoolPop(ptr %3)
ret i32 0
}
; Function Attrs: nounwind
declare ptr @llvm.objc.autoreleasePoolPush() #2
declare void @NSLog(ptr noundef, ...) local_unnamed_addr #3
; Function Attrs: nounwind
declare void @llvm.objc.autoreleasePoolPop(ptr) #2
attributes #0 = { "objc_arc_inert" }
attributes #1 = { ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nounwind }
attributes #3 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7, !8, !9, !10}
!llvm.ident = !{!11}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
!2 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!3 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!4 = !{i32 1, !"Objective-C Garbage Collection", i8 0}
!5 = !{i32 1, !"Objective-C Class Properties", i32 64}
!6 = !{i32 1, !"Objective-C Enforce ClassRO Pointer Signing", i8 0}
!7 = !{i32 1, !"wchar_size", i32 4}
!8 = !{i32 8, !"PIC Level", i32 2}
!9 = !{i32 7, !"uwtable", i32 1}
!10 = !{i32 7, !"frame-pointer", i32 1}
!11 = !{!"Homebrew clang version 21.1.3"}
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="sysctl-debugger-check" -S test.ll -o obf.ll
DebuggerCheckPass: Injected sysctl into function 'main'
Check the output, note that the __check_debugger() function call has been added:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.m"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
%struct.__NSConstantString_tag = type { ptr, i32, ptr, i64 }
@__CFConstantStringClassReference = external global [0 x i32]
@.str = private unnamed_addr constant [14 x i8] c"Hello, World!\00", section "__TEXT,__cstring,cstring_literals", align 1
@_unnamed_cfstring_ = private global %struct.__NSConstantString_tag { ptr @__CFConstantStringClassReference, i32 1992, ptr @.str, i64 13 }, section "__DATA,__cfstring", align 8 #0
; Function Attrs: ssp uwtable(sync)
define noundef i32 @main(i32 noundef %0, ptr noundef readnone captures(none) %1) local_unnamed_addr #1 {
call void @__check_debugger()
%3 = tail call ptr @llvm.objc.autoreleasePoolPush() #2
notail call void (ptr, ...) @NSLog(ptr noundef nonnull @_unnamed_cfstring_)
tail call void @llvm.objc.autoreleasePoolPop(ptr %3)
ret i32 0
}
; Function Attrs: nounwind
declare ptr @llvm.objc.autoreleasePoolPush() #2
declare void @NSLog(ptr noundef, ...) local_unnamed_addr #3
; Function Attrs: nounwind
declare void @llvm.objc.autoreleasePoolPop(ptr) #2
define internal void @__check_debugger() {
entry:
%mib = alloca [4 x i32], align 4
%0 = getelementptr [4 x i32], ptr %mib, i32 0, i32 0
%1 = getelementptr [4 x i32], ptr %mib, i32 0, i32 1
%2 = getelementptr [4 x i32], ptr %mib, i32 0, i32 2
%3 = getelementptr [4 x i32], ptr %mib, i32 0, i32 3
store i32 1, ptr %0, align 4
store i32 14, ptr %1, align 4
store i32 1, ptr %2, align 4
%4 = call i32 @getpid()
store i32 %4, ptr %3, align 4
%info = alloca [648 x i8], align 1
call void @llvm.memset.p0.i64(ptr align 8 %info, i8 0, i64 648, i1 false)
%size = alloca i64, align 8
store i64 648, ptr %size, align 8
%5 = call i32 @sysctl(ptr %mib, i32 4, ptr %info, ptr %size, ptr null, i64 0)
%6 = getelementptr i8, ptr %info, i32 32
%p_flag = load i32, ptr %6, align 4
%7 = and i32 %p_flag, 2048
%8 = icmp ne i32 %7, 0
br i1 %8, label %debugged, label %not_debugged
debugged: ; preds = %entry
call void @exit(i32 1)
unreachable
not_debugged: ; preds = %entry
ret void
}
declare i32 @sysctl(ptr, i32, ptr, ptr, ptr, i64)
declare i32 @getpid()
; Function Attrs: nocallback nofree nounwind willreturn memory(argmem: write)
declare void @llvm.memset.p0.i64(ptr writeonly captures(none), i8, i64, i1 immarg) #4
declare void @exit(i32)
attributes #0 = { "objc_arc_inert" }
attributes #1 = { ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nounwind }
attributes #3 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #4 = { nocallback nofree nounwind willreturn memory(argmem: write) }
!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7, !8, !9, !10}
!llvm.ident = !{!11}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
!2 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!3 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!4 = !{i32 1, !"Objective-C Garbage Collection", i8 0}
!5 = !{i32 1, !"Objective-C Class Properties", i32 64}
!6 = !{i32 1, !"Objective-C Enforce ClassRO Pointer Signing", i8 0}
!7 = !{i32 1, !"wchar_size", i32 4}
!8 = !{i32 8, !"PIC Level", i32 2}
!9 = !{i32 7, !"uwtable", i32 1}
!10 = !{i32 7, !"frame-pointer", i32 1}
!11 = !{!"Homebrew clang version 21.1.3"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang -framework Foundation obf.ll -o obf && ./obf
2025-11-07 15:39:09.295 obf[8609:363432] Hello, World!
Run the executables with a debugger:
$ clang test.m -O3 -framework Foundation -o test
$ lldb ./test -o "run" -o "exit"
(lldb) target create "./test"
Current executable set to '/Users/gemesa/git-repos/phantom-pass/src/13-sysctl-debugger-check/test' (arm64).
(lldb) run
2025-11-07 15:39:34.867501+0100 test[8621:363797] Hello, World!
Process 8621 launched: '/Users/gemesa/git-repos/phantom-pass/src/13-sysctl-debugger-check/test' (arm64)
Process 8621 exited with status = 0 (0x00000000)
(lldb) exit
$ lldb ./obf -o "run" -o "exit"
(lldb) target create "./obf"
Current executable set to '/Users/gemesa/git-repos/phantom-pass/src/13-sysctl-debugger-check/obf' (arm64).
(lldb) run
Process 8632 launched: '/Users/gemesa/git-repos/phantom-pass/src/13-sysctl-debugger-check/obf' (arm64)
Process 8632 exited with status = 1 (0x00000001)
(lldb) exit
Indirect call (via subtract)
An LLVM pass that replaces direct function calls with indirect ones. Function addresses are encoded by subtracting a random offset and stored in the binary. At runtime the original addresses are decoded by adding the offset back (encoded address = address - offset, this means address = encoded address + offset). The pass can be applied to all functions (-passes="sub-indirect-call") or only to specified ones (-passes="sub-indirect-call<main>").
Known limitations:
- increased code size
- increased runtime penalty
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.m -O3 -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [9 x i8] c"Sum: %d\0A\00", align 1
; Function Attrs: nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
%1 = tail call i32 @add(i32 noundef 5, i32 noundef 10) #3
%2 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef %1)
ret i32 0
}
declare i32 @add(i32 noundef, i32 noundef) local_unnamed_addr #1
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
attributes #0 = { nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #3 = { nounwind }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.4"}
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="sub-indirect-call" -S test.ll -o obf.ll
Replacing call to add
Replacing call to printf
SubIndirectCallPass: calls replaced in function 'main'
Check the output, note the sub_icall.offsets and sub_icall.encoded globals and the indirect calls:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [9 x i8] c"Sum: %d\0A\00", align 1
@.sub_icall.offsets = internal constant [2 x i64] [i64 197, i64 59]
@.sub_icall.encoded = internal constant [2 x i64] [i64 sub (i64 ptrtoint (ptr @add to i64), i64 197), i64 sub (i64 ptrtoint (ptr @printf to i64), i64 59)]
; Function Attrs: nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #0 {
%1 = load volatile i64, ptr @.sub_icall.offsets, align 8
%2 = load volatile i64, ptr @.sub_icall.encoded, align 8
%3 = add i64 %2, %1
%4 = inttoptr i64 %3 to ptr
%5 = tail call i32 %4(i32 noundef 5, i32 noundef 10) #3
%6 = load volatile i64, ptr getelementptr inbounds ([2 x i64], ptr @.sub_icall.offsets, i64 0, i64 1), align 8
%7 = load volatile i64, ptr getelementptr inbounds ([2 x i64], ptr @.sub_icall.encoded, i64 0, i64 1), align 8
%8 = add i64 %7, %6
%9 = inttoptr i64 %8 to ptr
%10 = tail call i32 (ptr, ...) %9(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef %5)
ret i32 0
}
declare i32 @add(i32 noundef, i32 noundef) local_unnamed_addr #1
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
attributes #0 = { nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #3 = { nounwind }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.4"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang -framework Foundation obf.ll -o obf && ./obf
Sum: 15
CFG flattening
An LLVM pass that flattens the CFG (Control Flow Graph). The normal control flow of a function is transformed into a single loop with a switch-case dispatcher. All basic blocks are placed inside the loop and a state variable determines which block executes next. Block IDs are randomized for additional obfuscation.
Known limitations:
- slightly increased code size
- slightly increased runtime penalty
Currently supported:
- entry blocks with unconditional branches
- unconditional branches between non-entry blocks
- conditional branches between non-entry blocks
Not yet supported:
- entry blocks with conditional branches
- entry blocks with switch instructions
- switch instructions in non-entry blocks
The source code is available here.
Generate the IR for our main() test code:
Note: we do not optimize the generated IR in this case before applying our obfuscation pass. The reason is that the obfuscation pass only supports entry blocks with unconditional branches. After optimization, the unconditional branches might get replaced with conditional ones when compiling the test code.
$ clang test.c -O0 -Xclang -disable-O0-optnone -fno-discard-value-names -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128"
target triple = "x86_64-unknown-linux-gnu"
@.str = private unnamed_addr constant [12 x i8] c"result: %d\0A\00", align 1
; Function Attrs: noinline nounwind uwtable
define dso_local i32 @sum_to_n(i32 noundef %n) #0 {
entry:
%n.addr = alloca i32, align 4
%sum = alloca i32, align 4
%i = alloca i32, align 4
store i32 %n, ptr %n.addr, align 4
store i32 0, ptr %sum, align 4
store i32 0, ptr %i, align 4
br label %for.cond
for.cond: ; preds = %for.inc, %entry
%0 = load i32, ptr %i, align 4
%1 = load i32, ptr %n.addr, align 4
%cmp = icmp slt i32 %0, %1
br i1 %cmp, label %for.body, label %for.end
for.body: ; preds = %for.cond
%2 = load i32, ptr %i, align 4
%3 = load i32, ptr %sum, align 4
%add = add nsw i32 %3, %2
store i32 %add, ptr %sum, align 4
br label %for.inc
for.inc: ; preds = %for.body
%4 = load i32, ptr %i, align 4
%inc = add nsw i32 %4, 1
store i32 %inc, ptr %i, align 4
br label %for.cond, !llvm.loop !6
for.end: ; preds = %for.cond
%5 = load i32, ptr %sum, align 4
ret i32 %5
}
; Function Attrs: noinline nounwind uwtable
define dso_local i32 @main() #0 {
entry:
%retval = alloca i32, align 4
%val = alloca i32, align 4
store i32 0, ptr %retval, align 4
%call = call i32 @sum_to_n(i32 noundef 10)
store i32 %call, ptr %val, align 4
%0 = load i32, ptr %val, align 4
%call1 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %0)
ret i32 0
}
declare i32 @printf(ptr noundef, ...) #1
attributes #0 = { noinline nounwind uwtable "frame-pointer"="all" "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
attributes #1 = { "frame-pointer"="all" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{i32 8, !"PIC Level", i32 2}
!2 = !{i32 7, !"PIE Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 2}
!4 = !{i32 7, !"frame-pointer", i32 2}
!5 = !{!"clang version 22.0.0git (git@github.com:llvm/llvm-project.git ab665f217ac573c473266a2b188b1f5a0eaa946e)"}
!6 = distinct !{!6, !7}
!7 = !{!"llvm.loop.mustprogress"}
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="flatten-cfg<sum_to_n>" -S test.ll -o obf.ll
Dispatcher:
Block 'for.cond' assigned ID: 3
Block 'for.body' assigned ID: 2
Block 'for.inc' assigned ID: 0
Block 'for.end' assigned ID: 1
FlattenCfgPass: CFG flattened in function 'sum_to_n'
Check the output, note that the basic block terminators are replaced with dispatcher logic:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128"
target triple = "x86_64-unknown-linux-gnu"
@.str = private unnamed_addr constant [12 x i8] c"result: %d\0A\00", align 1
; Function Attrs: noinline nounwind uwtable
define dso_local i32 @sum_to_n(i32 noundef %n) #0 {
entry:
%state = alloca i32, align 4
%n.addr = alloca i32, align 4
%sum = alloca i32, align 4
%i = alloca i32, align 4
store i32 %n, ptr %n.addr, align 4
store i32 0, ptr %sum, align 4
store i32 0, ptr %i, align 4
store i32 3, ptr %state, align 4
br label %dispatcher
for.cond: ; preds = %dispatcher
%0 = load i32, ptr %i, align 4
%1 = load i32, ptr %n.addr, align 4
%cmp = icmp slt i32 %0, %1
%2 = select i1 %cmp, i32 2, i32 1
store i32 %2, ptr %state, align 4
br label %dispatcher
for.body: ; preds = %dispatcher
%3 = load i32, ptr %i, align 4
%4 = load i32, ptr %sum, align 4
%add = add nsw i32 %4, %3
store i32 %add, ptr %sum, align 4
store i32 0, ptr %state, align 4
br label %dispatcher
for.inc: ; preds = %dispatcher
%5 = load i32, ptr %i, align 4
%inc = add nsw i32 %5, 1
store i32 %inc, ptr %i, align 4
store i32 3, ptr %state, align 4
br label %dispatcher
for.end: ; preds = %dispatcher
%6 = load i32, ptr %sum, align 4
ret i32 %6
dispatcher: ; preds = %for.inc, %for.body, %for.cond, %entry, %loop_end
%state_val = load i32, ptr %state, align 4
switch i32 %state_val, label %loop_end [
i32 3, label %for.cond
i32 2, label %for.body
i32 0, label %for.inc
i32 1, label %for.end
]
loop_end: ; preds = %dispatcher
br label %dispatcher
}
; Function Attrs: noinline nounwind uwtable
define dso_local i32 @main() #0 {
entry:
%retval = alloca i32, align 4
%val = alloca i32, align 4
store i32 0, ptr %retval, align 4
%call = call i32 @sum_to_n(i32 noundef 10)
store i32 %call, ptr %val, align 4
%0 = load i32, ptr %val, align 4
%call1 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %0)
ret i32 0
}
declare i32 @printf(ptr noundef, ...) #1
attributes #0 = { noinline nounwind uwtable "frame-pointer"="all" "min-legal-vector-width"="0" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
attributes #1 = { "frame-pointer"="all" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="x86-64" "target-features"="+cmov,+cx8,+fxsr,+mmx,+sse,+sse2,+x87" "tune-cpu"="generic" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 1, !"wchar_size", i32 4}
!1 = !{i32 8, !"PIC Level", i32 2}
!2 = !{i32 7, !"PIE Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 2}
!4 = !{i32 7, !"frame-pointer", i32 2}
!5 = !{!"clang version 22.0.0git (git@github.com:llvm/llvm-project.git ab665f217ac573c473266a2b188b1f5a0eaa946e)"}
Graphs:
Note: the graphs are generated via the following commands:
$ opt -passes=dot-cfg test.ll -disable-output
$ dot -Tsvg .sum_to_n.dot -o 15-cfg-flattening-cfg-before.svg
$ opt -passes=dot-cfg obf.ll -disable-output
$ dot -Tsvg .sum_to_n.dot -o 15-cfg-flattening-cfg-after.svg
Before:
After:
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
result: 45
Indirect branch
An LLVM pass that replaces direct branches with indirect branches (through a jump table). Instead of jumping directly to target blocks, the pass creates a global array of block addresses and converts branches to load the target address from the table before jumping. The jump table is shuffled for additional obfuscation.
Known limitations:
- slightly increased code size
- slightly increased runtime penalty
- indirect branches may limit certain compiler optimizations (because they make it harder for the compiler to reason about control flow)
Currently supported:
- unconditional branches
- conditional branches
Not yet supported:
- switch instructions
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.c -O3 -fno-discard-value-names -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [12 x i8] c"result: %d\0A\00", align 1
; Function Attrs: mustprogress nofree norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define i32 @sum_to_n(i32 noundef %n) local_unnamed_addr #0 {
entry:
%cmp4 = icmp sgt i32 %n, 0
br i1 %cmp4, label %for.body.preheader, label %for.cond.cleanup
for.body.preheader: ; preds = %entry
%0 = add nsw i32 %n, -1
%1 = zext nneg i32 %0 to i33
%2 = add nsw i32 %n, -2
%3 = zext i32 %2 to i33
%4 = mul i33 %1, %3
%5 = lshr i33 %4, 1
%6 = trunc nuw i33 %5 to i32
%7 = add i32 %n, %6
%8 = add i32 %7, -1
br label %for.cond.cleanup
for.cond.cleanup: ; preds = %for.body.preheader, %entry
%sum.0.lcssa = phi i32 [ 0, %entry ], [ %8, %for.body.preheader ]
ret i32 %sum.0.lcssa
}
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #1 {
entry:
%call1 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef 45)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
attributes #0 = { mustprogress nofree norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.8"}
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="indirect-branch" -S test.ll -o obf.ll
IndirectBranchPass: branches replaced in function 'sum_to_n'
IndirectBranchPass: there are no branches to replace in function 'main'
Check the output, note that the direct branches have been replaced with indirect ones:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [12 x i8] c"result: %d\0A\00", align 1
@jump_table = private constant [2 x ptr] [ptr blockaddress(@sum_to_n, %for.body.preheader), ptr blockaddress(@sum_to_n, %for.cond.cleanup)]
; Function Attrs: mustprogress nofree norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define i32 @sum_to_n(i32 noundef %n) local_unnamed_addr #0 {
entry:
%cmp4 = icmp sgt i32 %n, 0
%0 = select i1 %cmp4, ptr @jump_table, ptr getelementptr inbounds ([2 x ptr], ptr @jump_table, i64 0, i64 1)
%indirect_target = load ptr, ptr %0, align 8
indirectbr ptr %indirect_target, [label %for.body.preheader, label %for.cond.cleanup]
for.body.preheader: ; preds = %entry
%1 = add nsw i32 %n, -1
%2 = zext nneg i32 %1 to i33
%3 = add nsw i32 %n, -2
%4 = zext i32 %3 to i33
%5 = mul i33 %2, %4
%6 = lshr i33 %5, 1
%7 = trunc nuw i33 %6 to i32
%8 = add i32 %n, %7
%9 = add i32 %8, -1
%indirect_target1 = load ptr, ptr getelementptr inbounds ([2 x ptr], ptr @jump_table, i64 0, i64 1), align 8
indirectbr ptr %indirect_target1, [label %for.cond.cleanup]
for.cond.cleanup: ; preds = %for.body.preheader, %entry
%sum.0.lcssa = phi i32 [ 0, %entry ], [ %9, %for.body.preheader ]
ret i32 %sum.0.lcssa
}
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #1 {
entry:
%call1 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef 45)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
attributes #0 = { mustprogress nofree norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.8"}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
result: 45
Opaque predicate
An LLVM pass that obfuscates conditional branches by combining them with opaque predicates (expressions that always evaluate to known value but are hard to prove statically). The original branch condition is ANDed with the opaque predicate, so the program behavior stays the same but the control flow becomes harder to analyze.
The opaque predicates are based on table 1 (page 5) of When Are Opaque Predicates Useful?.
Known limitations:
- slightly increased code size
- slightly increased runtime penalty
- can be attacked/defeated by methods detailed in the paper above
The source code is available here.
Generate the IR for our main() test code:
Note: we do not optimize the generated IR in this case before applying our obfuscation pass. The reason is that the conditional branches might get replaced when compiling the test code.
$ clang test.c -O0 -Xclang -disable-O0-optnone -fno-discard-value-names -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [16 x i8] c"check(%d) = %d\0A\00", align 1
; Function Attrs: noinline nounwind ssp uwtable(sync)
define i32 @check(i32 noundef %x) #0 {
entry:
%retval = alloca i32, align 4
%x.addr = alloca i32, align 4
store i32 %x, ptr %x.addr, align 4
%0 = load i32, ptr %x.addr, align 4
%cmp = icmp sgt i32 %0, 10
br i1 %cmp, label %if.then, label %if.else
if.then: ; preds = %entry
%1 = load i32, ptr %x.addr, align 4
%mul = mul nsw i32 %1, 2
store i32 %mul, ptr %retval, align 4
br label %return
if.else: ; preds = %entry
%2 = load i32, ptr %x.addr, align 4
%add = add nsw i32 %2, 5
store i32 %add, ptr %retval, align 4
br label %return
return: ; preds = %if.else, %if.then
%3 = load i32, ptr %retval, align 4
ret i32 %3
}
; Function Attrs: noinline nounwind ssp uwtable(sync)
define i32 @main() #0 {
entry:
%retval = alloca i32, align 4
%a = alloca i32, align 4
%b = alloca i32, align 4
store i32 0, ptr %retval, align 4
store i32 7, ptr %a, align 4
store i32 15, ptr %b, align 4
%0 = load i32, ptr %a, align 4
%1 = load i32, ptr %a, align 4
%call = call i32 @check(i32 noundef %1)
%call1 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %0, i32 noundef %call)
%2 = load i32, ptr %b, align 4
%3 = load i32, ptr %b, align 4
%call2 = call i32 @check(i32 noundef %3)
%call3 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %2, i32 noundef %call2)
ret i32 0
}
declare i32 @printf(ptr noundef, ...) #1
attributes #0 = { noinline nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.8"}
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="opaque-predicate" -S test.ll -o obf.ll
OpaquePredicatePass: predicates replaced in function 'check'
OpaquePredicatePass: no conditional branches in function 'main'
Check the output, note that the opaque predicate logic and condition have been added to function check:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [16 x i8] c"check(%d) = %d\0A\00", align 1
@opaque_x = private global i32 13
@opaque_y = private global i32 37
@opaque_x.1 = private global i32 13
@opaque_y.2 = private global i32 37
; Function Attrs: noinline nounwind ssp uwtable(sync)
define i32 @check(i32 noundef %x) #0 {
entry:
%retval = alloca i32, align 4
%x.addr = alloca i32, align 4
store i32 %x, ptr %x.addr, align 4
%0 = load i32, ptr %x.addr, align 4
%cmp = icmp sgt i32 %0, 10
%load_x = load i32, ptr @opaque_x, align 4
%load_y = load i32, ptr @opaque_y, align 4
%x2 = mul i32 %load_x, %load_x
%x2px = add i32 %x2, %load_x
%x2pxp7 = add i32 %x2px, 7
%mod_81 = srem i32 %x2pxp7, 81
%opaque_x2pxp7_mod81 = icmp ne i32 %mod_81, 0
%obf_cond = and i1 %cmp, %opaque_x2pxp7_mod81
br i1 %obf_cond, label %if.then, label %if.else
if.then: ; preds = %entry
%1 = load i32, ptr %x.addr, align 4
%mul = mul nsw i32 %1, 2
store i32 %mul, ptr %retval, align 4
br label %return
if.else: ; preds = %entry
%2 = load i32, ptr %x.addr, align 4
%add = add nsw i32 %2, 5
store i32 %add, ptr %retval, align 4
br label %return
return: ; preds = %if.else, %if.then
%3 = load i32, ptr %retval, align 4
ret i32 %3
}
; Function Attrs: noinline nounwind ssp uwtable(sync)
define i32 @main() #0 {
entry:
%retval = alloca i32, align 4
%a = alloca i32, align 4
%b = alloca i32, align 4
store i32 0, ptr %retval, align 4
store i32 7, ptr %a, align 4
store i32 15, ptr %b, align 4
%0 = load i32, ptr %a, align 4
%1 = load i32, ptr %a, align 4
%call = call i32 @check(i32 noundef %1)
%call1 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %0, i32 noundef %call)
%2 = load i32, ptr %b, align 4
%3 = load i32, ptr %b, align 4
%call2 = call i32 @check(i32 noundef %3)
%call3 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %2, i32 noundef %call2)
ret i32 0
}
declare i32 @printf(ptr noundef, ...) #1
attributes #0 = { noinline nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.8"}
If we load the binaries into Ghidra, we can see that the decompiler cannot simplify the opaque predicate away:
Before:
int _check(int param_1)
{
undefined4 local_4;
if (param_1 < 0xb) {
local_4 = param_1 + 5;
}
else {
local_4 = param_1 << 1;
}
return local_4;
}
After:
int _check(int param_1)
{
undefined4 local_4;
if ((param_1 < 0xb) || ((DAT_100008000 * DAT_100008000 + DAT_100008000 + 7) % 0x51 == 0)) {
local_4 = param_1 + 5;
}
else {
local_4 = param_1 << 1;
}
return local_4;
}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
check(7) = 12
check(15) = 30
Virtual machine (instruction-level)
An LLVM pass that replaces arithmetic instructions with calls to a register-based VM. Instead of executing add, sub, mul, etc. directly, operands are stored into a global register file and a bytecode blob is created for each instruction. Then __vm_exec(bytecode_ptr) reads the bytecode [opcode, dst, src0, src1], executes the operation via the proper VM handler and writes the result back to a destination register. This means that before calling __vm_exec, the inputs must be copied into the src0 and src1 registers and the result must be read from the dst register.
This is a simplified, instruction-level approach. Commercial tools usually virtualize entire functions or regions, use a single bytecode stream with a fetch-decode-execute (FDE) loop and hide control flow inside the VM. Here, we create separate bytecode blobs per instruction and keep branches and loops native.
Known limitations:
- significantly increased code size
- significantly increased runtime penalty
- control flow remains visible (not virtualized)
- no bytecode encryption
- the VM can be easily reversed
The source code is available here.
Generate the IR for our main() test code:
Note: we optimize the generated IR before applying our obfuscation pass.
$ clang test.c -O3 -fno-discard-value-names -S -emit-llvm -o test.ll
Check the output:
$ cat test.ll
; ModuleID = 'test.c'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [12 x i8] c"Result: %d\0A\00", align 1
; Function Attrs: mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define i32 @compute(i32 noundef %a, i32 noundef %b) local_unnamed_addr #0 {
entry:
%add = add nsw i32 %b, %a
%mul = shl nsw i32 %add, 1
%xor = xor i32 %mul, 255
%sub = sub nsw i32 %xor, %a
ret i32 %sub
}
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #1 {
entry:
%call = tail call i32 @compute(i32 noundef 10, i32 noundef 20)
%call1 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef %call)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
attributes #0 = { mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.8"}
Run the pass:
$ opt -load-pass-plugin=./obf.dylib -passes="virtual-machine<compute>" -S test.ll -o obf.ll
VirtualMachinePass: instructions replaced in function 'compute'
Check the output, note that the arithmetic instructions have been replaced with __vm_exec calls:
$ cat obf.ll
; ModuleID = 'test.ll'
source_filename = "test.c"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-n32:64-S128-Fn32"
target triple = "arm64-apple-macosx15.0.0"
@.str = private unnamed_addr constant [12 x i8] c"Result: %d\0A\00", align 1
@__vm_regs = private global [256 x i64] zeroinitializer
@__vm_bc_0 = private constant [4 x i8] c"\01\02\00\01"
@__vm_bc_1 = private constant [4 x i8] c"\07\02\00\01"
@__vm_bc_2 = private constant [4 x i8] c"\06\02\00\01"
@__vm_bc_3 = private constant [4 x i8] c"\02\02\00\01"
; Function Attrs: mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync)
define i32 @compute(i32 noundef %a, i32 noundef %b) local_unnamed_addr #0 {
entry:
%a_ext = sext i32 %b to i64
%b_ext = sext i32 %a to i64
store i64 %a_ext, ptr @__vm_regs, align 8
store i64 %b_ext, ptr getelementptr inbounds ([256 x i64], ptr @__vm_regs, i64 0, i64 1), align 8
call void @__vm_exec(ptr @__vm_bc_0)
%vm_result = load i64, ptr getelementptr inbounds ([256 x i64], ptr @__vm_regs, i64 0, i64 2), align 8
%vm_trunc = trunc i64 %vm_result to i32
%a_ext1 = sext i32 %vm_trunc to i64
store i64 %a_ext1, ptr @__vm_regs, align 8
store i64 1, ptr getelementptr inbounds ([256 x i64], ptr @__vm_regs, i64 0, i64 1), align 8
call void @__vm_exec(ptr @__vm_bc_1)
%vm_result2 = load i64, ptr getelementptr inbounds ([256 x i64], ptr @__vm_regs, i64 0, i64 2), align 8
%vm_trunc3 = trunc i64 %vm_result2 to i32
%a_ext4 = sext i32 %vm_trunc3 to i64
store i64 %a_ext4, ptr @__vm_regs, align 8
store i64 255, ptr getelementptr inbounds ([256 x i64], ptr @__vm_regs, i64 0, i64 1), align 8
call void @__vm_exec(ptr @__vm_bc_2)
%vm_result5 = load i64, ptr getelementptr inbounds ([256 x i64], ptr @__vm_regs, i64 0, i64 2), align 8
%vm_trunc6 = trunc i64 %vm_result5 to i32
%a_ext7 = sext i32 %vm_trunc6 to i64
%b_ext8 = sext i32 %a to i64
store i64 %a_ext7, ptr @__vm_regs, align 8
store i64 %b_ext8, ptr getelementptr inbounds ([256 x i64], ptr @__vm_regs, i64 0, i64 1), align 8
call void @__vm_exec(ptr @__vm_bc_3)
%vm_result9 = load i64, ptr getelementptr inbounds ([256 x i64], ptr @__vm_regs, i64 0, i64 2), align 8
%vm_trunc10 = trunc i64 %vm_result9 to i32
ret i32 %vm_trunc10
}
; Function Attrs: nofree nounwind ssp uwtable(sync)
define noundef i32 @main() local_unnamed_addr #1 {
entry:
%call = tail call i32 @compute(i32 noundef 10, i32 noundef 20)
%call1 = tail call i32 (ptr, ...) @printf(ptr noundef nonnull dereferenceable(1) @.str, i32 noundef %call)
ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @printf(ptr noundef readonly captures(none), ...) local_unnamed_addr #2
; Function Attrs: noinline optnone
define private void @__vm_exec(ptr %bytecode) #3 {
entry:
%op_ptr = getelementptr inbounds i8, ptr %bytecode, i64 0
%dst_ptr = getelementptr inbounds i8, ptr %bytecode, i64 1
%src0_ptr = getelementptr inbounds i8, ptr %bytecode, i64 2
%src1_ptr = getelementptr inbounds i8, ptr %bytecode, i64 3
%op = load i8, ptr %op_ptr, align 1
%dst = load i8, ptr %dst_ptr, align 1
%src0 = load i8, ptr %src0_ptr, align 1
%src1 = load i8, ptr %src1_ptr, align 1
%src0_ext = zext i8 %src0 to i64
%src1_ext = zext i8 %src1 to i64
%src0_reg_ptr = getelementptr inbounds [256 x i64], ptr @__vm_regs, i64 0, i64 %src0_ext
%src1_reg_ptr = getelementptr inbounds [256 x i64], ptr @__vm_regs, i64 0, i64 %src1_ext
%a = load i64, ptr %src0_reg_ptr, align 8
%b = load i64, ptr %src1_reg_ptr, align 8
switch i8 %op, label %default [
i8 1, label %add
i8 2, label %sub
i8 3, label %mul
i8 4, label %and
i8 5, label %or
i8 6, label %xor
i8 7, label %shl
i8 8, label %shr
]
add: ; preds = %entry
%add_res = add i64 %a, %b
%dst_ext = zext i8 %dst to i64
%dst_ptr1 = getelementptr inbounds [256 x i64], ptr @__vm_regs, i64 0, i64 %dst_ext
store i64 %add_res, ptr %dst_ptr1, align 8
ret void
sub: ; preds = %entry
%sub_res = sub i64 %a, %b
%dst_ext4 = zext i8 %dst to i64
%dst_ptr5 = getelementptr inbounds [256 x i64], ptr @__vm_regs, i64 0, i64 %dst_ext4
store i64 %sub_res, ptr %dst_ptr5, align 8
ret void
mul: ; preds = %entry
%mul_res = mul i64 %a, %b
%dst_ext2 = zext i8 %dst to i64
%dst_ptr3 = getelementptr inbounds [256 x i64], ptr @__vm_regs, i64 0, i64 %dst_ext2
store i64 %mul_res, ptr %dst_ptr3, align 8
ret void
and: ; preds = %entry
%and_res = and i64 %a, %b
%dst_ext6 = zext i8 %dst to i64
%dst_ptr7 = getelementptr inbounds [256 x i64], ptr @__vm_regs, i64 0, i64 %dst_ext6
store i64 %and_res, ptr %dst_ptr7, align 8
ret void
or: ; preds = %entry
%or_res = or i64 %a, %b
%dst_ext8 = zext i8 %dst to i64
%dst_ptr9 = getelementptr inbounds [256 x i64], ptr @__vm_regs, i64 0, i64 %dst_ext8
store i64 %or_res, ptr %dst_ptr9, align 8
ret void
xor: ; preds = %entry
%xor_res = xor i64 %a, %b
%dst_ext10 = zext i8 %dst to i64
%dst_ptr11 = getelementptr inbounds [256 x i64], ptr @__vm_regs, i64 0, i64 %dst_ext10
store i64 %xor_res, ptr %dst_ptr11, align 8
ret void
shl: ; preds = %entry
%shl_res = shl i64 %a, %b
%dst_ext12 = zext i8 %dst to i64
%dst_ptr13 = getelementptr inbounds [256 x i64], ptr @__vm_regs, i64 0, i64 %dst_ext12
store i64 %shl_res, ptr %dst_ptr13, align 8
ret void
shr: ; preds = %entry
%lshr_res = lshr i64 %a, %b
%dst_ext14 = zext i8 %dst to i64
%dst_ptr15 = getelementptr inbounds [256 x i64], ptr @__vm_regs, i64 0, i64 %dst_ext14
store i64 %lshr_res, ptr %dst_ptr15, align 8
ret void
default: ; preds = %entry
ret void
}
attributes #0 = { mustprogress nofree noinline norecurse nosync nounwind ssp willreturn memory(none) uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #1 = { nofree nounwind ssp uwtable(sync) "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #2 = { nofree nounwind "frame-pointer"="non-leaf" "no-trapping-math"="true" "stack-protector-buffer-size"="8" "target-cpu"="apple-m1" "target-features"="+aes,+altnzcv,+ccdp,+ccidx,+ccpp,+complxnum,+crc,+dit,+dotprod,+flagm,+fp-armv8,+fp16fml,+fptoint,+fullfp16,+jsconv,+lse,+neon,+pauth,+perfmon,+predres,+ras,+rcpc,+rdm,+sb,+sha2,+sha3,+specrestrict,+ssbs,+v8.1a,+v8.2a,+v8.3a,+v8.4a,+v8a" }
attributes #3 = { noinline optnone }
!llvm.module.flags = !{!0, !1, !2, !3, !4}
!llvm.ident = !{!5}
!0 = !{i32 2, !"SDK Version", [2 x i32] [i32 15, i32 5]}
!1 = !{i32 1, !"wchar_size", i32 4}
!2 = !{i32 8, !"PIC Level", i32 2}
!3 = !{i32 7, !"uwtable", i32 1}
!4 = !{i32 7, !"frame-pointer", i32 1}
!5 = !{!"Homebrew clang version 21.1.8"}
If we load the binaries into Ghidra, we can see that _compute is harder to understand than without the VM. Still, this is a very basic VM, so it can be reversed rather quickly.
Before:
int _compute(int param_1,int param_2)
{
return ((param_2 + param_1) * 2 ^ 0xffU) - param_1;
}
After:
undefined8 _compute(int param_1,int param_2)
{
DAT_100008000 = (long)param_2;
DAT_100008008 = (long)param_1;
FUN_100000668(&DAT_100000860);
DAT_100008000 = (long)(int)DAT_100008010;
DAT_100008008 = 1;
FUN_100000668(&DAT_100000864);
DAT_100008000 = (long)(int)DAT_100008010;
DAT_100008008 = 0xff;
FUN_100000668(&DAT_100000868);
DAT_100008000 = (long)(int)DAT_100008010;
DAT_100008008 = (long)param_1;
FUN_100000668(&DAT_10000086c);
return DAT_100008010;
}
void FUN_100000668(char *param_1)
{
char cVar1;
byte bVar2;
ulong uVar3;
ulong uVar4;
cVar1 = *param_1;
bVar2 = param_1[1];
uVar4 = (&DAT_100008000)[(byte)param_1[2]];
uVar3 = (&DAT_100008000)[(byte)param_1[3]];
if (cVar1 == '\x01') {
(&DAT_100008000)[bVar2] = uVar4 + uVar3;
return;
}
if (cVar1 == '\x02') {
(&DAT_100008000)[bVar2] = uVar4 - uVar3;
return;
}
if (cVar1 == '\x03') {
(&DAT_100008000)[bVar2] = uVar4 * uVar3;
return;
}
if (cVar1 == '\x04') {
(&DAT_100008000)[bVar2] = uVar4 & uVar3;
return;
}
if (cVar1 == '\x05') {
(&DAT_100008000)[bVar2] = uVar4 | uVar3;
return;
}
if (cVar1 == '\x06') {
(&DAT_100008000)[bVar2] = uVar4 ^ uVar3;
return;
}
if (cVar1 == '\a') {
(&DAT_100008000)[bVar2] = uVar4 << (uVar3 & 0x3f);
return;
}
if (cVar1 != '\b') {
return;
}
(&DAT_100008000)[bVar2] = uVar4 >> (uVar3 & 0x3f);
return;
}
Build the modified IR and run the executable:
Note: do not pass
-O3or other optimization-related options at this point as they might interfere with the applied obfuscation methods.
$ clang obf.ll -o obf && ./obf
Result: 185