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 and Hancitor) 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 install LLVM via brew but only add the missing tools to the path to avoid conflicts.
$ 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
Alternatively, add /opt/homebrew/opt/llvm/bin to the path. This will shadow the preinstalled LLVM tools.
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...