Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

Hello, world!

A simple LLVM pass that inserts a puts("Hello, world!") call into main().

Known limitations:

  • puts is 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 -O3 or 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 -O3 or 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 -O3 or 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 -O3 or 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 -O3 or 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 -O3 or 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 -O3 or 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 -O3 or 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 -O3 or 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 -O3 or 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 -O3 or 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 -O3 or 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 -O3 or 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...