이 글은 두 번째 히카리 소스 코드 분석이며, 첫 번째 글은 여기 (), 주로 분석 구현의 안티 디버그 기능에 대한 글, 소스 코드 위치: 더 이상 고민하지 말고 시작하세요.
프레임워크 분석
안티 디버그의 원칙은 주로 패스 분석의 특정 구현을 위해 세부 사항으로 이동하지 않습니다. PASS는 컴파일 된 프로그램의 디버깅에 대한 저항력을 높이기 위해 설계되었습니다 , 먼저 첫인상의 논리의 전반적인 구현을 빗질 한 다음 자세한 설명을 개발 한 다음 자세한 설명을 개발합니다. 디버깅 방지 기능을 달성하는 방법은 크게 두 가지입니다:
미리 컴파일된 디버깅 IR 코드 연결하기
이 코드는 지정된 경로에서 미리 컴파일된 디버그 IR 파일을 로드하려고 시도합니다. 파일이 성공적으로 로드되면 Linker::linkModules 함수를 통해 현재 모듈에 링크됩니다. 이 사전 컴파일된 IR에는 예를 들어 디버깅을 위한 함수 및 구조체 집합이 포함될 수 있습니다:
- 디버거 코드를 감지합니다.
- 디버거가 제대로 추적하지 못하도록 자체 실행 경로를 수정합니다.
- 디버깅 환경에서 발생할 수 있는 비정상적인 동작을 감지하기 위해 코드를 스테이킹합니다.
- 생성된 인라인 어셈블리 코드는 시스템 호출을 사용하여 디버깅 방지 동작을 트리거하려고 시도할 수 있습니다(예: 디버깅 중인지 여부를 감지하기 위한 ptrace 호출).
- InlineAsm::get을 사용하여 인라인 어셈블리 객체를 생성한 다음 함수의 마지막 인스트럭션(일반적으로 함수의 반환 인스트럭션)에 삽입합니다.
코드 분석
config
코드 시작 부분에 정의된 두 개의 정적 전역 명령줄 옵션으로 구성에 대한 간단한 설명부터 시작하겠습니다:
PreCompiledIRPath 명령줄 옵션을 선택합니다:
static cl::opt<std::string> PreCompiledIRPath(
"adbextirpath",
cl::desc("External Path Pointing To Pre-compiled AntiDebugging IR"),
cl::value_desc("filename"), cl::init(""));
cl::opt<std::string>std::string 유형의 명령줄 옵션을 정의합니다.- "adbextirpath"는 명령줄 옵션의 이름이자 명령줄에서 옵션을 지정할 때 사용되는 플래그입니다.
- cl::desc는 이 옵션에 대한 설명을 제공하여 이 옵션이 미리 컴파일된 디버그 IR 파일의 외부 경로를 지정하는 데 사용됨을 사용자에게 알려줍니다.
- cl::value_desc는 명령줄 인자에 대한 설명으로, 인자가 파일 이름이어야 함을 사용자에게 알려줍니다.
- cl::init("")은 이 옵션의 기본값을 초기화하며, 이 경우 기본적으로 경로가 지정되지 않았음을 나타내는 빈 문자열입니다.
ProbRate 명령줄 옵션을 선택합니다:
static cl::opt<uint32_t> ProbRate(
"adb_prob",
cl::desc("Choose the probability [%] For Each Function To Be "
"Obfuscated By AntiDebugging"),
cl::value_desc("Probability Rate"), cl::init(40), cl::Optional);
cl::opt<uint32_t>uint32_t 유형의 명령줄 옵션을 정의합니다.- "adb_prob"는 이 명령줄 옵션의 이름입니다.
- cl::desc 이 매개변수는 각 함수가 안티 디버깅에 의해 난독화될 확률을 결정하는 백분율을 설정합니다.
- cl::value_desc는 명령줄 옵션에서 예상되는 값의 유형을 설명하는 데 사용되며, 이 경우 사용자는 "확률 비율"을 제공해야 합니다.
- cl::init(40)은 이 옵션의 기본값이 40이라는 의미로, 사용자가 명령줄에서 이 옵션을 지정하지 않으면 자동으로 40%로 설정됩니다.
- cl::옵션 이 명령줄 옵션은 선택 사항이며 사용자가 제공 여부를 선택할 수 있음을 나타냅니다.
요약하면, 사용자는 명령줄에서 -adbextirpath 옵션을 사용하여 미리 컴파일된 디버그 IR 파일의 경로를 지정할 수 있으며, -adb_prob 옵션을 사용하여 각 함수가 난독화될 확률을 지정할 수도 있습니다.
config
다음 단계는 초기화 함수의 코드를 자세히 파싱하고 전체 로직을 정리하는 것입니다.
미리 컴파일된 IR 경로를 확인합니다:
먼저 PreCompiledIRPath가 비어 있는지 확인합니다. 비어 있으면 기본 경로를 빌드하려고 시도합니다. 사용자의 home_directory 디렉터리에 "Hikari"라는 폴더가 있다고 가정한 다음 현재 모듈의 대상 아키텍처 및 운영 체제 유형에 따라 파일 이름을 빌드합니다.
if (PreCompiledIRPath == "") {
SmallString<32> Path;
if (sys::path::home_directory(Path)) {
sys::path::append(Path, "Hikari");
Triple tri(M.getTargetTriple());
sys::path::append(Path, "PrecompiledAntiDebugging-" +
Triple::getArchTypeName(tri.getArch()) +
"-" + Triple::getOSTypeName(tri.getOS()) +
".bc");
PreCompiledIRPath = Path.c_str();
}
}
미리 컴파일된 IR을 연결합니다:
이 섹션에서는 먼저 ifstream 객체 f를 사용하여 파일이 존재하는지 확인합니다. 파일이 존재하면 미리 컴파일된 IR 파일을 연결하려고 시도합니다. 파일이 존재하지 않거나 읽을 수 없는 경우 오류 메시지가 출력됩니다.
std::ifstream f(PreCompiledIRPath);
if (f.good()) {
errs() << "Linking PreCompiled AntiDebugging IR From:" << PreCompiledIRPath << "
";
SMDiagnostic SMD;
std::unique_ptr<Module> ADBM(
parseIRFile(StringRef(PreCompiledIRPath), SMD, M.getContext()));
Linker::linkModules(M, std::move(ADBM), Linker::Flags::LinkOnlyNeeded);
// ... (코드 일부 생략)
} else {
errs() << "Failed To Link PreCompiled AntiDebugging IR From:" << PreCompiledIRPath << "
";
}
ADBCallBack 및 InitADB 함수의 속성을 수정합니다:
ADBCallBack 함수가 발견되면 선언이 아닌지 확인한 다음 가시성, 연결 속성 및 함수 속성을 변경하여 최적화 및 연결 중에 동작을 보장합니다.
// ... (앞의 링크된 코드)
Function *ADBCallBack = M.getFunction("ADBCallBack");
if (ADBCallBack) {
assert(!ADBCallBack->isDeclaration() && "AntiDebuggingCallback is not concrete!");
ADBCallBack->setVisibility(GlobalValue::VisibilityTypes::HiddenVisibility);
ADBCallBack->setLinkage(GlobalValue::LinkageTypes::PrivateLinkage);
ADBCallBack->removeFnAttr(Attribute::AttrKind::NoInline);
ADBCallBack->removeFnAttr(Attribute::AttrKind::OptimizeNone);
ADBCallBack->addFnAttr(Attribute::AttrKind::AlwaysInline);
}
// ... (InitADB 처리도 마찬가지로)
초기화 플래그와 대상 트라이어드 정보를 설정합니다:
미리 컴파일된 IR을 성공적으로 연결하면 초기화된 플래그가 true로 설정되고 모듈의 트리플 정보가 저장됩니다.
this->initialized = true;
this->triple = Triple(M.getTargetTriple());
결국 초기화 메서드는 작업을 완료하면 참을 반환하며, 이러한 방식으로 프로그램이 컴파일될 때 이 LLVM 패스를 포함하면 각 모듈에 대해 미리 컴파일된 IR을 초기화하고 연결하는 프로세스를 제공하여 디버깅 방지 코드를 이식할 수 있습니다. 초기화에 실패하면 오류를 출력하고 패스의 추가 실행을 중지할 수 있습니다.
실행 모듈 및 실행 함수
runOnModule
실행온모듈 함수는 비교적 간단하고 전체적인 로직이 명확하며, 설정된 확률 값을 사용하여 모듈의 개별 함수에 디버깅 방지 난독화를 적용할지 여부를 결정합니다. 먼저 사용자가 입력한 확률 값이 합리적인 범위 내에 있는지 확인한 다음, 모듈의 모든 함수를 반복하여 특정 함수가 아닌 함수에 대한 난독화 적용 여부를 toObfuscate 함수와 확률 판단을 통해 결정합니다. 난독화가 필요한 경우 그에 따라 난독화가 수행되고 이 과정에서 필요한 데이터 구조가 초기화됩니다.
bool runOnModule(Module &M) override {
if (ProbRate > 100) {
errs() << "AntiDebugging application function percentage "
"-adb_prob=x must be 0 < x <= 100";
return false;
}
for (Function &F : M) {
if (toObfuscate(flag, &F, "adb") && F.getName() != "ADBCallBack" &&
F.getName() != "InitADB") {
errs() << "Running AntiDebugging On " << F.getName() << "
";
if (!this->initialized)
initialize(M);
if (cryptoutils->get_range(100) <= ProbRate)
runOnFunction(F);
}
}
return true;
}
runOnFunction
이 기능은 전체 PASS의 핵심 기능이며, 기능에 대한 자세한 분석이 될 것입니다.
함수 F에 대한 엔트리블록을 가져옵니다.
함수의 첫 번째 기본 블록을 가져옵니다. 일반적으로 초기화 코드나 기타 선제적 로직을 삽입하는 데 사용됩니다.
BasicBlock *EntryBlock = &(F.getEntryBlock());
ADBCallBack 및 InitADB 함수에 대한 참조를 얻으려고 합니다.
현재 함수가 있는 모듈에서 ADBCallBack 및 InitADB라는 이름의 함수를 가져옵니다.
Function *ADBCallBack = F.getParent()->getFunction("ADBCallBack");
Function *ADBInit = F.getParent()->getFunction("InitADB");
ADBCallBack그리고 InitADB 함수를 처리해야 합니다.
이 두 함수의 처리가 발견되면 항목 기본 블록에 InitADB에 대한 호출이 생성됩니다.
ADBCallBack 또는 InitADB를 찾을 수 없는 경우 오류 메시지가 출력되고 반환 유형이 무효가 아닌 경우 함수 F가 false를 반환합니다.
if (ADBCallBack && ADBInit) {
CallInst::Create(ADBInit, "",
cast<Instruction>(EntryBlock->getFirstInsertionPt()));
} else {
errs() << "The ADBCallBack and ADBInit functions were not found
";
if (!F.getReturnType()
->isVoidTy()) // We insert InlineAsm in the Terminator, which
// causes register contamination if the return type
// is not Void.
return false;
대상 운영 체제 및 아키텍처를 검토하고 인라인 어셈블리 코드 문자열을 구성합니다.
대상 시스템이 Darwin이고 아키텍처가 AArch64인 경우, 실행은 계속 진행되며 이후 인라인 어셈블리 코드의 구성을 위해 빈 문자열을 초기화합니다.
if (triple.isOSDarwin() && triple.isAArch64()) {
errs() << "Injecting Inline Assembly AntiDebugging For:"
<< F.getParent()->getTargetTriple() << "
";
std::string antidebugasm = "";
난수를 기반으로 항바이러스제를 채우는 데 사용할 지침 집합을 결정합니다.
다른 코드 경로는 무작위 함수 get_range(2)에 의해 선택됩니다.
switch (cryptoutils->get_range(2)) {
명령어 조각을 무작위로 선택하여 안티버그가즘으로 연결합니다.
루프와 무작위 선택을 사용하여 각 지침 세트가 적어도 한 번 이상 사용된 다음 안티버그 문자열에 연결되도록 합니다.
case 0: {
std::string s[] = {"mov x0, #31
", "mov w0, #31
", "mov x1, #0
",
"mov w1, #0
", "mov x2, #0
", "mov w2, #0
",
"mov x3, #0
", "mov w3, #0
", "mov x16, #26
",
"mov w16, #26
"}; // svc ptrace
bool c[5] = {false, false, false, false, false};
while (c[0] != true || c[1] != true || c[2] != true || c[3] != true ||
c[4] != true) {
// ...
}
InlineAsm 객체 IA를 생성하고 함수 종료 명령어 앞에 삽입합니다.
문자열 안티버그에 어셈블리 코드가 포함된 인라인 어셈블리 객체를 생성합니다.
InlineAsm *IA = InlineAsm::get(FunctionType::get(Type::getVoidTy(EntryBlock->getContext()), false), antidebugasm, "", true, false);
함수의 각 기본 블록 끝에 인라인 어셈블리를 추가합니다.
함수의 모든 기본 블록을 반복하고 내부 버전 적응을 통해 각 기본 블록의 종료 명령어 앞에 인라인 어셈블리 호출을 삽입합니다.
Instruction *I = nullptr;
for (BasicBlock &BB : F)
I = BB.getTerminator();
CallInst::Create(IA, std::nullopt, "", I);
#if LLVM_VERSION_MAJOR >= 16
CallInst::Create(IA, std::nullopt, "", I);
#else
CallInst::Create(IA, None, "", I);
#endif
운영 체제 및 아키텍처에서 지원하지 않는 경우 오류 메시지를 출력합니다.
예상되는 운영 체제 및 아키텍처가 아닌 경우 오류 메시지가 출력됩니다.
} else {
errs() << "Unsupported Inline Assembly AntiDebugging Target: " << F.getParent()->getTargetTriple() << "
";
}
위의 코드를 통해 일반적인 프로세스는 주로 첫 번째 ADBCallBack 및 InitADB 함수 획득 및 호출, 그리고 난수 패딩 및 기타 보안 수단을 사용하는 과정에서 인라인 어셈블리 삽입을 위한 Darwin 시스템 ARM64 아키텍처의 경우 어셈블리를 통해 svc ptrace 호출을 달성하는 것입니다.
미리 컴파일된 디버깅 IR 파일
위의 분석에서 코드 로직이 약간의 디버깅 방지 로직이 수행되는 PreCompiledIRPath 매개 변수를 통해 ADBCallBack 및 InitADB 함수가 포함된 IR 파일을 설정한다는 것을 알 수 있습니다. 이 파일에 대한 다음 분석은 다음과 같습니다. 히카리 원저자가 제공한 IR 파일의 주소는 github.com/HikariObfus... 이며, 파일 구조는 다음과 같습니다:
PrecompiledAntiDebugging-aarch64-ios.bc
PrecompiledAntiDebugging-thumb-ios.bc
PrecompiledAntiDebugging-x86_64-macosx.bc
SymbolConfig.json
PrecompiledAntiDebugging-aarch64-ios.bc 대해서만 분석된 .bc 파일은 LLVM의 중간 표현을 컴파일된 바이너리 형식으로 담은 LLVM 비트코드 파일 형식입니다. .bc 파일의 내용을 보려면 텍스트 LLVM IR로 변환해야 합니다. 이 변환은 LLVM 툴체인의 llvm-dis 도구를 사용하여 수행합니다. 변환된 파일의 확장자는 일반적으로 .ll이며, 이는 읽을 수 있는 LLVM IR 파일입니다.
llvm-dis <input.bc> -o <output.ll>
독자는 코드의 양이 많기 때문에 해당 코드는 여기에 제공되지 않으며, 다음에 분석할 IR 파일에 대해 자체 변환으로 이동할 수 있습니다.
구조 정의:
글로벌 선언:
- .str은 문자열 "ptrace":
%struct.ios_execp_info전역 선언입니다. @mach_task_self_ = external global i32, align 4외부 전역 변수 선언입니다.
함수 ADBCallBack:
ADBCallBack 함수는 프로그램을 종료한 다음 연결할 수 없는 명령어를 실행하기 위해 abort() 함수를 호출하는 비교적 간단한 함수입니다.
define void @ADBCallBack() #0 {
call void @abort() #4
unreachable
}
InitADB 함수를 호출합니다:
이 함수에는 여러 시스템 호출과 검사가 포함되어 있으며 주요 로직은 다음과 같습니다:
- sysctl을 사용하여 프로세스 정보를 쿼리하기:
%18 = call i32 @sysctl(ptr %16, i32 4, ptr %17, ptr %3, ptr null, i64 0). - 프로세스 일부 상태 확인: %22 = 및 i32 %21, 4028, %23 = icmp ne i32 %22, 0
- 디버그 상태가 감지되면 ADBCallBack 함수
%22 = and i32 %21, 4028호출합니다. - 라이브러리를 동적으로 로드 및 언로드하려고 시도하며, 디버거가 동적 링크 프로세스에 개입했는지 감지하려고 시도하는 경우: dlopen 및 dlsym 호출:
%23 = icmp ne i32 %22, 0. - 시스템 호출에 syscall을 사용하면 더 많은 내부 정보를 확인할 수 있습니다: syscall call
call void @ADBCallBack() - 메모리를 동적으로 할당하고,
%26 = call ptr @dlopen(ptr null, i32 10)호출하여 디버거가 연결되어 있는지 확인하는 데 사용할 수 있는 예외 포트를 확인합니다 . - 터미널에서 프로그램이 실행 중인지 여부와 터미널의 상태를 확인하는 데 자주 사용되는 isatty와 ioctl이 비정상적으로 동작하는지 확인합니다. 81 = call i32 @isatty(i32 1) with
%34 = call i32 (i32, ...) @syscall(i32 26, i32 31, i32 0, i32 0)
시스템 호출 및 선언:
예를 들어 함수 선언 섹션에는 여러 시스템 호출이 포함되어 있습니다:
task_get_exception_portsdeclare i32 @getpid() #2declare ptr @malloc(i64) #3declare i32 @task_get_exception_ports(i32, i32, ptr, ptr, ptr, ptr, ptr) #2declare i32 @isatty(i32) #2declare i32 @ioctl(i32, i64, ...) #2
속성:
함수 속성은 코드 끝부분에 속성 키워드로 정의됩니다:
attributes #0 = { noinline nounwind optnone ssp uwtable ... }
attributes #1 = { noreturn "correctly-rounded-divide-sqrt-fp-math"="false" ...}
attributes #2 = ...
모듈 표시 및 라벨링:
모듈의 컴파일러 플래그와 식별 정보는 코드 끝에 제공됩니다:
!llvm.module.flags = !{!0, !1}
!llvm.ident = !{!2}
위의 IR 코드는 디버깅을 감지하고 방지하도록 설계되었습니다. 어떤 조건이 디버거 실행과 일치하거나 정상적으로 실행 중인 프로그램에서 예상되는 것과 다르다는 것을 감지하는 즉시 ADBCallBack을 호출하여 프로그램을 종료합니다. 코드에 대한 일반적인 분석을 수행합니다:
구조 정의: 코드는 iOS 운영 체제와의 상호 작용 및 메모리 데이터 구성에 사용할 수 있는 여러 구조의 정의로 시작합니다.
전역 선언: @.str은 문자열 "ptrace"를 저장하는 비공개, 이름 없는 주소 상수입니다. mach_task_self_는 현재 작업의 신원을 나타낼 수 있는 외부 전역 변수입니다.
ADBCallBack 함수: 이 함수는 매우 간단하며, abort() 함수를 호출하여 프로그램을 종료한 다음 일반적으로 디버깅 방지 로직의 일부인 도달할 수 없는 명령을 실행합니다.
함수 InitADB: 이 함수는 디버깅 방지 로직의 핵심입니다. 일련의 시스템 호출과 검사를 수행합니다:
- sysctl을 사용하여 프로세스 정보를 쿼리합니다.
- 프로세스의 특정 상태를 확인합니다.
- 디버그 상태가 감지되면 ADBCallBack 함수를 호출합니다.
- 라이브러리를 동적으로 로드 및 언로드하려는 시도는 디버거가 동적 링크 프로세스에 개입했는지 감지하려는 시도일 수 있습니다.
- 시스템 호출에 syscall을 사용하는 것은 보다 근본적인 점검을 수행하는 방법일 수 있습니다.
- 메모리를 동적으로 할당하고
task_get_exception_ports호출하여 디버거가 연결되어 있는지 확인하는 데 사용할 수 있는 예외 포트를 확인합니다. - 몇 가지 검사를 반복하여 예외가 발견되면 각 루프에서 ADBCallBack을 호출합니다.
- 마지막으로, 일반적으로 터미널에서 프로그램이 실행 중인지, 터미널의 상태는 어떤지 확인하는 데 사용되는 isatty와 ioctl이 비정상적으로 동작하지 않는지 확인합니다.
시스템 호출 및 선언: 이 코드는 getpid, sysctl, dlopen, dlsym, dlclose, syscall, malloc,
task_get_exception_ports, isatty, ioctl 등 여러 시스템 함수를 선언합니다. 이러한 함수는 다양한 시스템 수준 작업을 수행하는 데 사용되며, 그중 대부분은 디버깅 방지와 관련되어 있습니다.속성: 인라이닝하지 않기, 예외를 던지지 않기 등과 같이 컴파일러에 최적화된 함수 속성을 정의합니다.
모듈 플래그 및 식별자: wchar_size 및 PIC 수준과 같은 일부 컴파일러 관련 메타데이터를 선언합니다.
요약하자면
이 글에서는 자세한 코드 분석과 IR 파일 해석을 통해 LLVM PASS 기반의 안티디버그가 어떻게 구현되는지 알아보고, 마지막으로 소스 코드와 비교하여 두 가지의 차이점은 무엇인지 정리해 보았습니다.
프로젝트에서 직접 안티디버그를 구현하는 것은 일반적으로 소스 코드 수준에서 디버거를 감지하는 로직을 추가하는 반면, LLVM 패스를 기반으로 안티디버그를 구현하면 컴파일러 최적화 단계에서 이러한 종류의 로직을 삽입할 수 있습니다. 이 둘의 장점은 다음과 같은 방식으로 비교할 수 있습니다:
은폐:
- 소스 코드 구현: 안티 디버깅은 소스 코드에서 구현되며, 로직은 숙련된 개발자나 공격자가 볼 수 있으며 소스 코드를 읽음으로써 감지 및 우회할 수 있습니다.
- LLVM 패스 구현: LLVM 패스를 통해 삽입된 디버깅 방지 로직은 컴파일된 바이너리에서 구현되므로 탐지 및 리버스 엔지니어링이 더 어려워지고 디버깅 방지 조치의 보이지 않는 부분이 개선됩니다.
휴대성:
- 소스 코드 구현: 소스 코드 기반 디버깅은 다양한 플랫폼과 컴파일러에 맞게 조정하고 수정해야 합니다.
- LLVM Pass 구현: 크로스 플랫폼 컴파일러인 LLVM은 다양한 대상 아키텍처를 지원합니다. LLVM Pass를 사용하면 여러 플랫폼에서 디버깅 방지 로직의 일관성과 이식성을 보장할 수 있습니다.
유연성 및 재사용성:
- 소스 코드 구현: 디버깅 방지 코드를 코드에 수동으로 추가해야 하며, 대규모 프로젝트의 경우 유사한 코드를 여러 곳에 반복적으로 추가해야 할 수도 있습니다.
- LLVM 패스 구현: 컴파일 프로세스의 일부로 사용하여 대상 프로그램의 여러 부분에 디버깅 방지 코드를 자동으로 삽입할 수 있으므로 여러 프로젝트에서 쉽게 재사용할 수 있습니다.
유지 관리 가능성:
- 소스 코드 구현: 프로젝트가 성장함에 따라 소스 코드에 포함된 디버깅 방지 로직을 유지 관리하고 업데이트하는 작업이 복잡해질 수 있습니다.
- LLVM Pass 구현: 디버깅 방지 로직이 애플리케이션 로직과 분리되어 있어 유지 관리가 더 간단해집니다. 새로운 안티 디버깅 기술이 등장하면 LLVM Pass를 업데이트하기만 하면 됩니다.
성능:
- 소스 코드 구현: 추가 검사를 추가하여 프로그램 성능에 영향을 줄 수 있습니다.
- LLVM 패스 구현: 컴파일 중에 디버깅 방지 코드를 삽입할 시기와 위치를 더 스마트하게 선택할 수 있어 잠재적으로 더 나은 성능 최적화를 위한 여지가 생깁니다.
혼란의 정도:
- 소스 코드 구현: 일반적으로 더 간단하고 쉽게 되돌릴 수 있습니다.
- LLVM 패스 구현: 컴파일러 최적화 및 난독화 전략과 결합하여 더 복잡하고 분석하기 어려운 바이너리 코드를 생성할 수 있습니다.
요약하면, LLVM 패스를 기반으로 안티디버그를 구현하면 스테가노그래피, 이식성, 유연성, 유지보수성이 향상되고 성능과 난독화 수준에서 이점을 제공할 수 있습니다. 하지만 이 접근 방식은 LLVM 프레임워크에 대한 심층적인 지식이 필요하며 빌드 및 디버깅 프로세스가 더 복잡할 수 있습니다.





