はじめに
- 1 概要・論文編
- 2-1 LLVM 関数計装編 (本記事)
基礎技術研究部の末吉です。
前回の記事では ASan の概要と使い方、論文について解説しました。 今回からは執筆時点最新版の 2024 年 5 月にリリースされた LLVM 18.1.5 のコードから、どのように ASan が実装されているか解説します。 ちなみに、最近公開された Ubuntu 24.04 でも、バージョン 18 系の LLVM が使われています。
本記事は、これから ASan の実装を読んでいきたい方や前回の記事で紹介した手法がどのように実装されているのか気になっている方を対象読者としています。 今回は、関数への計装を取り上げて解説します。
LLVM のコードを読む
- 18.1.5 のコードリポジトリ: llvm/llvm-project/tree/llvmorg-18.1.5
LLVM での ASan 計装処理は llvm/lib/Transforms/Instrumentation/AddressSanitizer.cpp
に実装されています。
サニタイザーのライタイムライブラリは compiler-rt の一部として提供されていて、compiler-rt/lib/asan
に実装されています。
まずは ASan がどのように計装されるのか、AddressSanitizer.cpp から見ていきましょう。
LLVM は LLVM Pass と呼ばれる仕組みで計装します。
これは簡単にいうと、コンパイル処理に手を加えてアプリケーションの動作を変更できる機能です。
ASan を計装するクラス AddressSanitizerPass
もこの仕組みで計装しています。
サニタイザーのほかに、カバレッジを収集する計装 (SanitizerCoverage) などもあります。
計装処理のエントリーポイントは AddressSanitizerPass::run
です。
まずはこの関数を見てみましょう。
PreservedAnalyses AddressSanitizerPass::run(Module &M, ModuleAnalysisManager &MAM) { ModuleAddressSanitizer ModuleSanitizer( M, Options.InsertVersionCheck, Options.CompileKernel, Options.Recover, UseGlobalGC, UseOdrIndicator, DestructorKind, ConstructorKind); bool Modified = false; auto &FAM = MAM.getResult<FunctionAnalysisManagerModuleProxy>(M).getManager(); const StackSafetyGlobalInfo *const SSGI = ClUseStackSafety ? &MAM.getResult<StackSafetyGlobalAnalysis>(M) : nullptr; for (Function &F : M) { AddressSanitizer FunctionSanitizer( M, SSGI, Options.InstrumentationWithCallsThreshold, Options.MaxInlinePoisoningSize, Options.CompileKernel, Options.Recover, Options.UseAfterScope, Options.UseAfterReturn); const TargetLibraryInfo &TLI = FAM.getResult<TargetLibraryAnalysis>(F); Modified |= FunctionSanitizer.instrumentFunction(F, &TLI); } Modified |= ModuleSanitizer.instrumentModule(M); if (!Modified) return PreservedAnalyses::all(); PreservedAnalyses PA = PreservedAnalyses::none(); // GlobalsAA is considered stateless and does not get invalidated unless // explicitly invalidated; PreservedAnalyses::none() is not enough. Sanitizers // make changes that require GlobalsAA to be invalidated. PA.abandon<GlobalsAA>(); return PA; }
さて、この関数を眺めてまず目に入るのは ModuleSanitizer
と FunctionSanitizer
でしょう。
これらは名前通りモジュールに計装するインスタンスと関数に計装するインスタンスです。
モジュールに含まれる関数を instrumentFunction
関数で計装した後、モジュール全体を instrumentModule
関数で計装します。
関数の計装
次は instrumentFunction
関数を見てみましょう。
bool AddressSanitizer::instrumentFunction(Function &F, const TargetLibraryInfo *TLI) { // (...省略...) for (auto &BB : F) { AllBlocks.push_back(&BB); TempsToInstrument.clear(); int NumInsnsPerBB = 0; for (auto &Inst : BB) { // (...省略...) } } // (...省略...) }
for (auto &BB : F)
で関数中の基本ブロックを取り出し、for (auto &Inst : BB)
で基本ブロック内の命令を取り出しています。
この for 文の中で計装すべき命令を抽出しています。
計装の対象となる命令は、以下のいずれかを満たしている命令です。 これ以外にも、オプションやターゲットなどによって色々な細かい条件はありますが、解説を簡潔にするため、ここでは割愛します。
- Load/Store/Call などのメモリ操作系であり、何らかの条件で計装されない特定のメモリを扱わない命令である (OperandsToInstrument)
- ポインタ比較もしくはポインタ減算命令である (PointerComparisonsOrSubtracts)
- memset/memcpy/memmove といった固有のメモリ操作関数である (IntrinToInstrument)
- Call/Invoke 命令であり、リターンしない命令である (NoReturnCalls)
それぞれで各々の計装が行われます。
通常であればソースコードに書いてある順に 1 から説明すべきですが、1 が一番複雑なため、比較的簡単な 2 〜 4 を先に説明します。
2. PointerComparisonsOrSubtracts
PointerComparisonsOrSubtracts はポインタ比較やポインタ減算命令を見つけたときに、その範囲が正当か検証する計装です。 前回の記事で紹介した不正なポインタペアを検知する仕組みは、ここで実装されています。
対象命令は以下の部分で抽出されます。
bool AddressSanitizer::instrumentFunction(Function &F, const TargetLibraryInfo *TLI) { // (...省略...) } else if (((ClInvalidPointerPairs || ClInvalidPointerCmp) && isInterestingPointerComparison(&Inst)) || ((ClInvalidPointerPairs || ClInvalidPointerSub) && isInterestingPointerSubtraction(&Inst))) { PointerComparisonsOrSubtracts.push_back(&Inst); // (...省略...) }
isInterestingPointerComparison
および isInterestingPointerSubtraction
関数で計装対象の命令を抽出します。
なお、説明は適宜コードの中にコメントで追記しています。
// This is a rough heuristic; it may cause both false positives and // false negatives. The proper implementation requires cooperation with // the frontend. static bool isInterestingPointerComparison(Instruction *I) { if (ICmpInst *Cmp = dyn_cast<ICmpInst>(I)) { // 比較命令か? if (!Cmp->isRelational()) // 相対比較でない場合は計装対象ではない return false; } else { return false; } return isPointerOperand(I->getOperand(0)) && // オペランドが両方ともポインタなら計装対象 isPointerOperand(I->getOperand(1)); } // This is a rough heuristic; it may cause both false positives and // false negatives. The proper implementation requires cooperation with // the frontend. static bool isInterestingPointerSubtraction(Instruction *I) { if (BinaryOperator *BO = dyn_cast<BinaryOperator>(I)) { // 二項演算命令か? if (BO->getOpcode() != Instruction::Sub) // 減算命令でなければ計装対象ではない return false; } else { return false; } return isPointerOperand(I->getOperand(0)) && // オペランドが両方ともポインタなら計装対象 isPointerOperand(I->getOperand(1)); }
計装対象になった命令は以下の部分で計装します。
bool AddressSanitizer::instrumentFunction(Function &F, const TargetLibraryInfo *TLI) { // (...省略...) for (auto *Inst : PointerComparisonsOrSubtracts) { instrumentPointerComparisonOrSubtraction(Inst); FunctionModified = true; } // (...省略...) }
instrumentPointerComparisonOrSubtraction
関数を見てみます。
比較命令なら AsanPtrCmpFunction
, 減算命令なら AsanPtrSubFunction
を呼び出し、その引数には元の命令に与える予定の 2 つのポインタを渡す計装を入れています。
void AddressSanitizer::instrumentPointerComparisonOrSubtraction( Instruction *I) { IRBuilder<> IRB(I); FunctionCallee F = isa<ICmpInst>(I) ? AsanPtrCmpFunction : AsanPtrSubFunction; // 計装コードの関数を得る Value *Param[2] = {I->getOperand(0), I->getOperand(1)}; // 元の命令のオペランドを取り出す for (Value *&i : Param) { if (i->getType()->isPointerTy()) i = IRB.CreatePointerCast(i, IntptrTy); // IntptrTy はポインタのサイズ分のバイト数 } IRB.CreateCall(F, Param); // F((void*)Param[0], (void*)Param[1]) を計装 }
AsanPtr*
は、initializeCallbacks
関数内で代入されています。
void AddressSanitizer::initializeCallbacks(Module &M, const TargetLibraryInfo *TLI) { // (...省略...) AsanPtrCmpFunction = M.getOrInsertFunction(kAsanPtrCmp, IRB.getVoidTy(), IntptrTy, IntptrTy); // (関数名, 返り値型, 引数型, ...) AsanPtrSubFunction = M.getOrInsertFunction(kAsanPtrSub, IRB.getVoidTy(), IntptrTy, IntptrTy); // (...省略...) }
kAsanPtrCmp
と kAsanPtrSub
が、この関数の処理が書かれている関数名を指します。
この定義は AddressSanitizer.cpp 内に書かれています。
const char kAsanPtrCmp[] = "__sanitizer_ptr_cmp"; const char kAsanPtrSub[] = "__sanitizer_ptr_sub";
つまり、instrumentPointerComparisonOrSubtraction
関数は void __sanitizer_ptr_cmp(void* a, void* b)
もしくは void __sanitizer_ptr_sub(void* a, void* b)
という関数を計装する、という処理を行います。
この計装を C に直すと、以下のような感じになります。
// 比較命令に渡される引数 p1, p2 が既に存在する void* a = (void*)p1; void* b = (void*)p2; __sanitizer_ptr_cmp(a, b); 本来の比較処理(p1, p2)
次は __sanitizer_ptr_cmp
関数が何をしているか調べてみましょう。これは compiler-rt の asan_report.cpp
で定義されています。
compiler-rt 内はコンパイラが実行する処理ではなく、アプリケーション内で実行される計装コードです。
混同しないように注意してください。
SANITIZER_INTERFACE_ATTRIBUTE void __sanitizer_ptr_sub(void *a, void *b) { CheckForInvalidPointerPair(a, b); } SANITIZER_INTERFACE_ATTRIBUTE void __sanitizer_ptr_cmp(void *a, void *b) { CheckForInvalidPointerPair(a, b); }
見てわかるように、sub も cmp も同じ処理です。
CheckForInvalidPointerPair
関数を見てみましょう。
static inline void CheckForInvalidPointerPair(void *p1, void *p2) { switch (flags()->detect_invalid_pointer_pairs) { // 設定を確認 case 0: return; case 1: if (p1 == nullptr || p2 == nullptr) // どちらかがヌルポインタなら検証せずに終了 return; break; } uptr a1 = reinterpret_cast<uptr>(p1); // uptr (== unsigned long) にキャスト uptr a2 = reinterpret_cast<uptr>(p2); if (IsInvalidPointerPair(a1, a2)) { // 不正なポインタペアだった場合、検知 GET_CALLER_PC_BP_SP; ReportInvalidPointerPair(pc, bp, sp, a1, a2); } }
flags()
は ASAN_OPTIONS
に指定したランタイムフラグを返す関数です。
flags()->detect_invalid_pointer_flags
は ASAN_OPTIONS
に指定したランタイムフラグ detect_invalid_pointer_flags
の値を取得しています。
0
の時は、不正なポインタペアを検証せずそのままリターンする1
の時は、片方がヌルポインタの場合は検証せずにリターンする2
の時は、片方がヌルポインタであっても検証する
検証に入る場合は、対象のポインタ a1
, a2
を uptr (== unsigned long)
に変換後、IsInvalidPointerPair
を呼び出し、結果が true であれば不正なポインタペアとして検知します。
では、検知ロジックの本体である IsInvalidPointerPair
関数を見ていきましょう。
この関数では、2 つのポインタからなる範囲が異なる領域をまたぐ場合を不正なペアとしてみなします。
static bool IsInvalidPointerPair(uptr a1, uptr a2) { if (a1 == a2) // 同じアドレスを指す場合は不正なペアではない return false; // 256B in shadow memory can be iterated quite fast static const uptr kMaxOffset = 2048; uptr left = a1 < a2 ? a1 : a2; // 左端アドレス uptr right = a1 < a2 ? a2 : a1; // 右端アドレス uptr offset = right - left; // オフセット(範囲のバイト数) if (offset <= kMaxOffset) // 範囲が kMaxOffset バイト以下なら __asan_region_is_poisoned (⇒ 後述) で判定したほうが速い return __asan_region_is_poisoned(left, offset); AsanThread *t = GetCurrentThread(); // check whether left is a stack memory pointer // GetStackVariableShadowStart は引数アドレスに対応するスタックシャドウメモリのレッドゾーンをまたがない始端アドレスを返す // 対応するシャドウメモリがない (⇒ 引数はスタックアドレスではない) 場合、0 を返す if (uptr shadow_offset1 = t->GetStackVariableShadowStart(left)) { uptr shadow_offset2 = t->GetStackVariableShadowStart(right); // 左端はシャドウメモリだが、右端がシャドウメモリではない ⇒ 異なる領域をまたがっている不正な範囲 // もしくは、右端と左端の指すシャドウメモリ領域が異なる ⇒ レッドゾーンをまたがっている不正な範囲 (異なる変数を指すアドレスを範囲として指定している) return shadow_offset2 == 0 || shadow_offset1 != shadow_offset2; } // check whether left is a heap memory address HeapAddressDescription hdesc1, hdesc2; // GetHeapAddressInformation は、引数アドレスに対応するヒープチャンク情報を返す // 対応するチャンクがない (⇒ 引数はヒープアドレスではない) 場合、false を返す if (GetHeapAddressInformation(left, 0, &hdesc1) && hdesc1.chunk_access.access_type == kAccessTypeInside) // 左端はヒープ領域だが、右端はヒープ領域ではない ⇒ 異なる領域をまたがっている不正な範囲 // もしくは、左端と右端の指すチャンクの始端アドレスが異なる ⇒ 異なるヒープ領域をまたがっている不正な範囲 return !GetHeapAddressInformation(right, 0, &hdesc2) || hdesc2.chunk_access.access_type != kAccessTypeInside || hdesc1.chunk_access.chunk_begin != hdesc2.chunk_access.chunk_begin; // check whether left is an address of a global variable GlobalAddressDescription gdesc1, gdesc2; // GetGlobalAddressInformation は、引数アドレスに対応するグローバル変数情報を返す // 対応するグローバル変数がない (⇒ 引数はグローバル変数ではない) 場合、false を返す if (GetGlobalAddressInformation(left, 0, &gdesc1)) // 左端はグローバル領域だが、右端はグローバル領域ではない ⇒ 異なる領域をまたがっている不正な範囲 // もしくは、左端と右端の指すグローバル変数領域が異なる ⇒ 異なるグローバル変数を指している不正な範囲 return !GetGlobalAddressInformation(right - 1, 0, &gdesc2) || !gdesc1.PointsInsideTheSameVariable(gdesc2); if (t->GetStackVariableShadowStart(right) || GetHeapAddressInformation(right, 0, &hdesc2) || GetGlobalAddressInformation(right - 1, 0, &gdesc2)) // 左端がシャドウメモリでもヒープでもグローバル変数でもないのに右端はそれのいずれかに該当する // ⇒ 異なる領域をまたがっている不正な範囲 return true; // At this point we know nothing about both a1 and a2 addresses. // 2 つのアドレスは両方とも上のいずれでもない謎の領域であり、ASan の検証範囲外なので正当な範囲とみなす return false; }
さて、オフセットが kMaxOffset
以下の場合、__asan_region_is_poisoned
という関数が呼ばれます。
これは指定された範囲が汚染 (ポイズン) されているか (範囲にアドレス不可領域が存在するか) 調べる関数です。
// 汚染されている場合は汚染されている始端アドレスを返す。汚染されていない場合、0 を返す。 uptr __asan_region_is_poisoned(uptr beg, uptr size) { if (!size) // サイズがゼロなら汚染されていない return 0; uptr end = beg + size; // 終端アドレス if (!AddrIsInMem(beg)) return beg; if (!AddrIsInMem(end)) return end; CHECK_LT(beg, end); uptr aligned_b = RoundUpTo(beg, ASAN_SHADOW_GRANULARITY); // アラインメント後の始端アドレス uptr aligned_e = RoundDownTo(end, ASAN_SHADOW_GRANULARITY); // アラインメント後の終端アドレス uptr shadow_beg = MemToShadow(aligned_b); // 始端シャドウメモリ uptr shadow_end = MemToShadow(aligned_e); // 終端シャドウメモリ // First check the first and the last application bytes, // then check the ASAN_SHADOW_GRANULARITY-aligned region by calling // mem_is_zero on the corresponding shadow. if (!__asan::AddressIsPoisoned(beg) && !__asan::AddressIsPoisoned(end - 1) && (shadow_end <= shadow_beg || __sanitizer::mem_is_zero((const char *)shadow_beg, shadow_end - shadow_beg))) // 始端または終端が汚染されていない // かつ、範囲に対応するシャドウメモリの値が全てゼロ return 0; // The fast check failed, so we have a poisoned byte somewhere. // Find it slowly. // 愚直に汚染されているバイトを探す for (; beg < end; beg++) if (__asan::AddressIsPoisoned(beg)) // どこか一箇所でも汚染されていたらそのアドレスを返す return beg; UNREACHABLE("mem_is_zero returned false, but poisoned byte was not found"); return 0; }
これらがポインタペアの比較/減算のときに行われる検証処理です。
3. IntrinToInstrument
AddressSanitizer.cpp に戻って、次は IntrinToInstrument を見てみましょう。
bool AddressSanitizer::instrumentFunction(Function &F, const TargetLibraryInfo *TLI) { // (...省略...) for (auto *Inst : IntrinToInstrument) { if (!suppressInstrumentationSiteForDebug(NumInstrumented)) instrumentMemIntrinsic(Inst); FunctionModified = true; } // (...省略...) }
計装は instrumentMemIntrinsic
関数で行われます。
この関数は単純で、memset, memcpy, memmove をそれぞれ ASanMemset(__asan_memset
), ASanMemcpy(__asan_memcpy
), ASanMemmove(__asan_memmove
) に置き換えています。
void AddressSanitizer::instrumentMemIntrinsic(MemIntrinsic *MI) { InstrumentationIRBuilder IRB(MI); if (isa<MemTransferInst>(MI)) { IRB.CreateCall(isa<MemMoveInst>(MI) ? AsanMemmove : AsanMemcpy, // __asan_memmove/__asan_memcpy {MI->getOperand(0), MI->getOperand(1), // 第一、第二引数 (dest, src) は元の関数の引数型のまま IRB.CreateIntCast(MI->getOperand(2), IntptrTy, false)}); // 第三引数を intptr にキャスト } else if (isa<MemSetInst>(MI)) { IRB.CreateCall( AsanMemset, {MI->getOperand(0), // 第一引数 (s) はそのまま IRB.CreateIntCast(MI->getOperand(1), IRB.getInt32Ty(), false), // 第二引数 (c) を int にキャスト IRB.CreateIntCast(MI->getOperand(2), IntptrTy, false)}); // 第三引数 (n) を intptr にキャスト } MI->eraseFromParent(); // 元のメモリ操作を削除する }
__asan_memset
などの関数は compiler-rt の asan_interceptors_memintrinsics.cpp に書かれています。
// memcpy is called during __asan_init() from the internals of printf(...). // We do not treat memcpy with to==from as a bug. // See http://llvm.org/bugs/show_bug.cgi?id=11763. #define ASAN_MEMCPY_IMPL(ctx, to, from, size) \ do { \ if (LIKELY(replace_intrin_cached)) { \ if (LIKELY(to != from)) { \ CHECK_RANGES_OVERLAP("memcpy", to, size, from, size); \ // 範囲が重複していないか確認 } \ ASAN_READ_RANGE(ctx, from, size); \ // 読み込み権限の確認 ASAN_WRITE_RANGE(ctx, to, size); \ // 書き込み権限の確認 } else if (UNLIKELY(!AsanInited())) { \ return internal_memcpy(to, from, size); \ } \ return REAL(memcpy)(to, from, size); \ } while (0) // memset is called inside Printf. #define ASAN_MEMSET_IMPL(ctx, block, c, size) \ do { \ if (LIKELY(replace_intrin_cached)) { \ ASAN_WRITE_RANGE(ctx, block, size); \ // 書き込む } else if (UNLIKELY(!AsanInited())) { \ // ASan 初期化前に memset が呼ばれた場合は、模倣した memset を呼び出す return internal_memset(block, c, size); \ } \ return REAL(memset)(block, c, size); \ // 本物の memset を返す } while (0) #define ASAN_MEMMOVE_IMPL(ctx, to, from, size) \ do { \ if (LIKELY(replace_intrin_cached)) { \ ASAN_READ_RANGE(ctx, from, size); \ // 読み込み権限の確認 ASAN_WRITE_RANGE(ctx, to, size); \ // 書き込み権限の確認 } \ return internal_memmove(to, from, size); \ } while (0) void *__asan_memcpy(void *to, const void *from, uptr size) { ASAN_MEMCPY_IMPL(nullptr, to, from, size); } void *__asan_memset(void *block, int c, uptr size) { ASAN_MEMSET_IMPL(nullptr, block, c, size); } void *__asan_memmove(void *to, const void *from, uptr size) { ASAN_MEMMOVE_IMPL(nullptr, to, from, size); }
これらの関数では、元の関数の動作を模倣しつつ、ASan 固有の処理を行っています。
なお、 internal_*
関数は、最終的に __sanitizer_internal_*
関数を呼び出します。
それは sanitizer_libc.cpp
に書かれていて、元の関数を模倣したものになっています。
memcpy
と memset
にある return REAL(*)(...)
は元の関数を実行してその結果を返します。
この中で使われる ASAN_READ_RANGE
と ASAN_WRITE_RANGE
マクロは、アドレスがオーバーフローしないか、これから読み書きするメモリ領域が汚染されていないかを検証するマクロです。
これは asan_interceptors_memintrinsics.h に書かれていて、実体は ACCESS_MEMORY_RANGE
マクロです。
#define ACCESS_MEMORY_RANGE(ctx, offset, size, isWrite) \ do { \ uptr __offset = (uptr)(offset); \ uptr __size = (uptr)(size); \ uptr __bad = 0; \ if (UNLIKELY(__offset > __offset + __size)) { \ // offset + size がオーバーフローした場合は検知 GET_STACK_TRACE_FATAL_HERE; \ ReportStringFunctionSizeOverflow(__offset, __size, &stack); \ } \ if (UNLIKELY(!QuickCheckForUnpoisonedRegion(__offset, __size)) && \ // 指定の範囲が汚染されていた場合に検知 (__bad = __asan_region_is_poisoned(__offset, __size))) { \ // ただし、抑制されている場合は検知しない AsanInterceptorContext *_ctx = (AsanInterceptorContext *)ctx; \ bool suppressed = false; \ if (_ctx) { \ suppressed = IsInterceptorSuppressed(_ctx->interceptor_name); \ if (!suppressed && HaveStackTraceBasedSuppressions()) { \ GET_STACK_TRACE_FATAL_HERE; \ suppressed = IsStackTraceSuppressed(&stack); \ } \ } \ if (!suppressed) { \ GET_CURRENT_PC_BP_SP; \ ReportGenericError(pc, bp, sp, __bad, isWrite, __size, 0, false); \ } \ } \ } while (0) #define ASAN_READ_RANGE(ctx, offset, size) \ ACCESS_MEMORY_RANGE(ctx, offset, size, false) #define ASAN_WRITE_RANGE(ctx, offset, size) \ ACCESS_MEMORY_RANGE(ctx, offset, size, true)
4. NoReturnCalls
NoReturnCalls は他の計装と異なり、バグの検知ではなく誤検知防止のためにあります。
例外のスローや _exit
関数などを使用すると、関数はリターンすることなく終了します。
ASan はリターン時にスタック領域のレッドゾーン指定を解除する計装を入れるため、リターンしない場合は解除されず、再度その領域を使用しようとしたときに誤検知してしまいます。
これを防ぐための計装です。
bool AddressSanitizer::instrumentFunction(Function &F, const TargetLibraryInfo *TLI) { // (...省略...) // We must unpoison the stack before NoReturn calls (throw, _exit, etc). // See e.g. https://github.com/google/sanitizers/issues/37 for (auto *CI : NoReturnCalls) { IRBuilder<> IRB(CI); IRB.CreateCall(AsanHandleNoReturnFunc, {}); } // (...省略...) }
この計装は、リターンしない処理が呼ばれる直前に ASanHandleNoReturnFunc (__asan_handle_no_return
) 関数を計装するだけです。
__asan_handle_no_return
関数は、ポイズンしたスタック領域をアンポイズン (アクセス不可領域指定を解除) します。
void NOINLINE __asan_handle_no_return() { if (UNLIKELY(!AsanInited())) return; if (!PlatformUnpoisonStacks()) UnpoisonDefaultStack(); UnpoisonFakeStack(); }
1. OperandsToInstrument
さて、ここまでで 2 〜 4 を説明しました。 次は、後回しにしていた一番の山場 OperandsToInstrument について説明します。
bool AddressSanitizer::instrumentFunction(Function &F, const TargetLibraryInfo *TLI) { // (...省略...) for (auto &Operand : OperandsToInstrument) { if (!suppressInstrumentationSiteForDebug(NumInstrumented)) instrumentMop(ObjSizeVis, Operand, UseCalls, F.getParent()->getDataLayout()); FunctionModified = true; } // (...省略...) }
では instrumentMop
関数を見てみましょう…の前に、以下にコールフローを示します。
UnusualSizeOrAlignment ^ | | v instrumentMop -> doInstrumentAddress -> instrumentAddress | ^ v | MaskedLoadOrStore
instrumentMop
は道中で色々な計装関数を呼び出しますが、どの計装関数も instrumentAddress
を呼び出します。
instrumentMop
からトップダウン的に見ていくより instrumentAddress
からボトムアップ的に見ていくほうが理解しやすいと思うため、ここでは instrumentAddress
から見ていくことにします。
というわけで、まずは instrumentAddress
関数を見てみましょう。
void AddressSanitizer::instrumentAddress(Instruction *OrigIns, Instruction *InsertBefore, Value *Addr, MaybeAlign Alignment, uint32_t TypeStoreSize, bool IsWrite, Value *SizeArgument, bool UseCalls, uint32_t Exp) { if (TargetTriple.isAMDGPU()) { // AMD GPU の場合の計装 InsertBefore = instrumentAMDGPUAddress(OrigIns, InsertBefore, Addr, TypeStoreSize, IsWrite, SizeArgument); if (!InsertBefore) return; } InstrumentationIRBuilder IRB(InsertBefore); size_t AccessSizeIndex = TypeStoreSizeToSizeIndex(TypeStoreSize); const ASanAccessInfo AccessInfo(IsWrite, CompileKernel, AccessSizeIndex); if (UseCalls && ClOptimizeCallbacks) { // デフォルトでは ClOptimizeCallbacks が false なので通らない // (...省略...) } Value *AddrLong = IRB.CreatePointerCast(Addr, IntptrTy); // void* AddrLong = (void*)Addr; if (UseCalls) { // アドレスをインラインではなく関数を呼び出して検証する if (Exp == 0) IRB.CreateCall(AsanMemoryAccessCallback[IsWrite][0][AccessSizeIndex], // __asan_store8(AddrLong) など AddrLong); else IRB.CreateCall(AsanMemoryAccessCallback[IsWrite][1][AccessSizeIndex], // __asan_store8(AddrLong, (int)Exp) など {AddrLong, ConstantInt::get(IRB.getInt32Ty(), Exp)}); return; } Type *ShadowTy = IntegerType::get(*C, std::max(8U, TypeStoreSize >> Mapping.Scale)); // シャドウメモリの型を取得 Type *ShadowPtrTy = PointerType::get(ShadowTy, 0); // シャドウメモリのポインタ型を取得 Value *ShadowPtr = memToShadow(AddrLong, IRB); // シャドウメモリアドレスの取得 const uint64_t ShadowAlign = std::max<uint64_t>(Alignment.valueOrOne().value() >> Mapping.Scale, 1); // MemToShadow の計装 (Addr >> Scale の部分) Value *ShadowValue = IRB.CreateAlignedLoad( // シャドウメモリの値を取得 ShadowTy, IRB.CreateIntToPtr(ShadowPtr, ShadowPtrTy), Align(ShadowAlign)); Value *Cmp = IRB.CreateIsNotNull(ShadowValue); // *shadow != null size_t Granularity = 1ULL << Mapping.Scale; Instruction *CrashTerm = nullptr; ******************************: if (ClAlwaysSlowPath || (TypeStoreSize < 8 * Granularity)) { // デフォルトでは ClAlwaysSlowPath は false なので通らない // (...省略...) } else { // InsertBefore の直前に if (Cmp) 分岐を計装する CrashTerm = SplitBlockAndInsertIfThen(Cmp, InsertBefore, !Recover); // if (*MemToShadow(Addr) != 0) { CrashTerm } } ****************: bool GenSlowPath = (ClAlwaysSlowPath || (TypeStoreSize < 8 * Granularity)); if (TargetTriple.isAMDGCN()) { // AMD GCN の場合 // (...省略...) } else if (GenSlowPath) { // slow path 計装を行う // (...省略...) } else { // InsertBefore の直前に if (Cmp) 分岐を計装する // if (*MemToShadow(Addr) != 0) { CrashTerm } CrashTerm = SplitBlockAndInsertIfThen(Cmp, InsertBefore, !Recover); // if (*shadow != null) } // バグを検知してレポートを生成するコードを CrashTerm に計装 Instruction *Crash = generateCrashCode(CrashTerm, AddrLong, IsWrite, AccessSizeIndex, SizeArgument, Exp); if (OrigIns->getDebugLoc()) Crash->setDebugLoc(OrigIns->getDebugLoc()); }
さて、だいぶ長い関数です。 引数も多く、やっていることがなかなか複雑です。
instrumentAddress
は InsertBefore
(つまり、メモリ読み書き命令) の直前に以下のような計装コードを挿入します。
計装コードではシャドウメモリを見てアドレス可否を確認し、不可なら検証失敗を通知する __asan_report
関数を呼び出します。
if (*(char *)(((ulong)value >> Scale) + shadow_offset) != '\0') { __asan_report_load8(value); // 不正なメモリアクセスを検知 } 本来のメモリアクセス処理; // x = *value; など
ただし、UseCalls
の値によって計装方法が変化します。
UseCalls
は instrumentFunction
関数の中で決められています。
bool AddressSanitizer::instrumentFunction(Function &F, const TargetLibraryInfo *TLI) { // (...省略...) bool UseCalls = (InstrumentationWithCallsThreshold >= 0 && OperandsToInstrument.size() + IntrinToInstrument.size() > (unsigned)InstrumentationWithCallsThreshold); // (...省略...) }
デフォルト設定だと計装コードは上記のようにインライン展開された状態で計装されます。
しかし、巨大なアプリケーションだと計装量も膨大になってしまいます。
そのため、閾値として計装対象の命令数が InstrumentationWithCallsThreshold
を超えた場合はインライン展開の代わりに AsanMemoryAccessCallback
(__asan_store8
などの) 関数を呼び出すようになります。
__asan_load8(value); 本来のメモリアクセス処理; // x = *value; など
その閾値はデフォルトで 7000 ですが、ClInstrumentationWithCallsThreshold
オプションを介して変更できます。
このオプションを 0 にすれば全て関数で計装され、-1 にすれば全てインライン展開で計装されるようになります。
簡単に試してみたい場合は clang -fsanitize=address test1.c -mllvm -asan-instrumentation-with-call-threshold=0
でコンパイルしてみましょう。
小さなプログラムなら、当該オプションを指定したときとしないときで計装が変化するはずです。
次は、1 つ上に遡って doInstrumentAddress
関数を見てみましょう。
static void doInstrumentAddress(AddressSanitizer *Pass, Instruction *I, Instruction *InsertBefore, Value *Addr, MaybeAlign Alignment, unsigned Granularity, TypeSize TypeStoreSize, bool IsWrite, Value *SizeArgument, bool UseCalls, uint32_t Exp) { // Instrument a 1-, 2-, 4-, 8-, or 16- byte access with one check // if the data is properly aligned. if (!TypeStoreSize.isScalable()) { const auto FixedSize = TypeStoreSize.getFixedValue(); switch (FixedSize) { case 8: case 16: case 32: case 64: case 128: if (!Alignment || *Alignment >= Granularity || *Alignment >= FixedSize / 8) // 一般的なサイズの場合は instrumentAddress で計装する return Pass->instrumentAddress(I, InsertBefore, Addr, Alignment, FixedSize, IsWrite, nullptr, UseCalls, Exp); } } // あまり見かけないバイト数の場合は instrumentUnusualSizeOrAliginment で計装する Pass->instrumentUnusualSizeOrAlignment(I, InsertBefore, Addr, TypeStoreSize, IsWrite, nullptr, UseCalls, Exp); }
メモリアクセスのバイトサイズを確認して、一般的なサイズ (8, 16, 32, 64, 128) の場合は instrumentAddress
関数を呼び出します。
そうでない場合は instrumentUnusualSizeOrAlignment
を呼び出します。
void AddressSanitizer::instrumentUnusualSizeOrAlignment( Instruction *I, Instruction *InsertBefore, Value *Addr, TypeSize TypeStoreSize, bool IsWrite, Value *SizeArgument, bool UseCalls, uint32_t Exp) { InstrumentationIRBuilder IRB(InsertBefore); Value *NumBits = IRB.CreateTypeSize(IntptrTy, TypeStoreSize); Value *Size = IRB.CreateLShr(NumBits, ConstantInt::get(IntptrTy, 3)); // Size = ((size_t)TypeStoreSize) << 3; Value *AddrLong = IRB.CreatePointerCast(Addr, IntptrTy); // AddrLong = (size_t)Addr; if (UseCalls) { // 関数呼び出しの場合 if (Exp == 0) IRB.CreateCall(AsanMemoryAccessCallbackSized[IsWrite][0], // __asan_exp_storeN((void*)AddrLong, Size) など {AddrLong, Size}); else IRB.CreateCall(AsanMemoryAccessCallbackSized[IsWrite][1], // __asan_storeN((void*)AddrLong, Size, (int)Exp) など {AddrLong, Size, ConstantInt::get(IRB.getInt32Ty(), Exp)}); } else { Value *SizeMinusOne = IRB.CreateSub(Size, ConstantInt::get(IntptrTy, 1)); Value *LastByte = IRB.CreateIntToPtr( // LastByte = AddrLong + Size - 1 IRB.CreateAdd(AddrLong, SizeMinusOne), Addr->getType()); instrumentAddress(I, InsertBefore, Addr, {}, 8, IsWrite, Size, false, Exp); // 領域の先頭アドレスを検証 instrumentAddress(I, InsertBefore, LastByte, {}, 8, IsWrite, Size, false, // 領域の終端アドレスを検証 Exp); } }
UseCalls が true
の場合 (関数計装の場合) は AsanMemoryAccessCallbackSized
(__asan_storeN
系) 関数を計装します。
false
の場合 (インライン計装の場合) は、領域の先頭アドレスと終端アドレスを検証するように計装します。
次は、再び遡って instrumentMaskedLoadOrStore
関数を見てみましょう。
ここでいうマスクは、Masked Vector 関連の命令 の Mask を指します。
LLVM はベクトルに範囲指定でアクセスする際、マスクを掛けることで一部要素へのアクセスを除外する固有命令を持っています。それが Masked Vector 関連の命令です。
そのマスクを考慮した上で計装するのがこの関数です。
void AddressSanitizer::instrumentMaskedLoadOrStore( AddressSanitizer *Pass, const DataLayout &DL, Type *IntptrTy, Value *Mask, Value *EVL, Value *Stride, Instruction *I, Value *Addr, MaybeAlign Alignment, unsigned Granularity, Type *OpType, bool IsWrite, Value *SizeArgument, bool UseCalls, uint32_t Exp) { auto *VTy = cast<VectorType>(OpType); TypeSize ElemTypeSize = DL.getTypeStoreSizeInBits(VTy->getScalarType()); auto Zero = ConstantInt::get(IntptrTy, 0); IRBuilder IB(I); // 計装対象の命令 Instruction *LoopInsertBefore = I; // Explicit Vector Length(EVL)を計算するコードを計装する if (EVL) { // この if 文中では以下のような処理を生成している // // ``` // if (evl != 0) { // size_t evl2 = (size_t)evl; // size_t ec = VTy->getElementCount(); // evl = MIN(evl2, ec); // } // (引数 Iの命令) // ``` // // The end argument of SplitBlockAndInsertForLane is assumed bigger // than zero, so we should check whether EVL is zero here. Type *EVLType = EVL->getType(); Value *IsEVLZero = IB.CreateICmpNE(EVL, ConstantInt::get(EVLType, 0)); // evl != 0 LoopInsertBefore = SplitBlockAndInsertIfThen(IsEVLZero, I, false); // if (evl != 0) { LoopInsertBefore } IB.SetInsertPoint(LoopInsertBefore); // LoopInsertBefore ブロックに命令を追加していくようにする // Cast EVL to IntptrTy. EVL = IB.CreateZExtOrTrunc(EVL, IntptrTy); // size_t evl2 = (size_t)evl; // To avoid undefined behavior for extracting with out of range index, use // the minimum of evl and element count as trip count. Value *EC = IB.CreateElementCount(IntptrTy, VTy->getElementCount()); // size_t ec = VTy->getElementCount(); EVL = IB.CreateBinaryIntrinsic(Intrinsic::umin, EVL, EC); // evl = MIN(evl2, ec); } else { EVL = IB.CreateElementCount(IntptrTy, VTy->getElementCount()); // size_t ec = VTy->getElementCount(); } // Cast Stride to IntptrTy. if (Stride) // ストライドを使ったアクセスの場合 Stride = IB.CreateZExtOrTrunc(Stride, IntptrTy); // (size_t)Stride // ベクトルの各レーンについて、それぞれのアクセスとアドレスを計算して計装する SplitBlockAndInsertForEachLane(EVL, LoopInsertBefore, [&](IRBuilderBase &IRB, Value *Index) { Value *MaskElem = IRB.CreateExtractElement(Mask, Index); // MaskElem = Mask[Index]; if (auto *MaskElemC = dyn_cast<ConstantInt>(MaskElem)) { if (MaskElemC->isZero()) // MaskElem が 0 なら(そもそもアクセスしない場合)何もしない // No check return; // Unconditional check } else { // Conditional check Instruction *ThenTerm = SplitBlockAndInsertIfThen( // if (MaskElem) { ThenTerm } MaskElem, &*IRB.GetInsertPoint(), false); IRB.SetInsertPoint(ThenTerm); // ThenTerm ブロックに命令を追加していくようにする } Value *InstrumentedAddress; if (isa<VectorType>(Addr->getType())) { assert( cast<VectorType>(Addr->getType())->getElementType()->isPointerTy() && "Expected vector of pointer."); InstrumentedAddress = IRB.CreateExtractElement(Addr, Index); // InstrumentAddress = Addr[Index]; } else if (Stride) { Index = IRB.CreateMul(Index, Stride); // Index *= Stride InstrumentedAddress = IRB.CreatePtrAdd(Addr, Index); // InstrumentedAddress = Addr + Index } else { InstrumentedAddress = IRB.CreateGEP(VTy, Addr, {Zero, Index}); } // 指定した箇所を計装する doInstrumentAddress(Pass, I, &*IRB.GetInsertPoint(), InstrumentedAddress, Alignment, Granularity, ElemTypeSize, IsWrite, SizeArgument, UseCalls, Exp); }); }
まずベクトルの長さを計算し、その後ベクトルの各レーンに対してアクセスするアドレスを計算して計装していきます。
これでようやく instrumentMop
を見る準備が整ったので、見ていきましょう。
void AddressSanitizer::instrumentMop(ObjectSizeOffsetVisitor &ObjSizeVis, InterestingMemoryOperand &O, bool UseCalls, const DataLayout &DL) { Value *Addr = O.getPtr(); // Optimization experiments. // The experiments can be used to evaluate potential optimizations that remove // instrumentation (assess false negatives). Instead of completely removing // some instrumentation, you set Exp to a non-zero value (mask of optimization // experiments that want to remove instrumentation of this instruction). // If Exp is non-zero, this pass will emit special calls into runtime // (e.g. __asan_report_exp_load1 instead of __asan_report_load1). These calls // make runtime terminate the program in a special way (with a different // exit status). Then you run the new compiler on a buggy corpus, collect // the special terminations (ideally, you don't see them at all -- no false // negatives) and make the decision on the optimization. uint32_t Exp = ClForceExperiment; // デフォルトでは 0 if (ClOpt && ClOptGlobals) { // グローバル変数へのアクセスのうち、コンパイル時に安全だとわかるものは計装しない // If initialization order checking is disabled, a simple access to a // dynamically initialized global is always valid. GlobalVariable *G = dyn_cast<GlobalVariable>(getUnderlyingObject(Addr)); if (G && (!ClInitializers || GlobalIsLinkerInitialized(G)) && isSafeAccess(ObjSizeVis, Addr, O.TypeStoreSize)) { NumOptimizedAccessesToGlobalVar++; return; } } if (ClOpt && ClOptStack) { // スタック変数のうち、コンパイル時に安全だとわかるものは計装しない // デフォルトでは ClOptStack は false なので通らない // A direct inbounds access to a stack variable is always valid. if (isa<AllocaInst>(getUnderlyingObject(Addr)) && isSafeAccess(ObjSizeVis, Addr, O.TypeStoreSize)) { NumOptimizedAccessesToStackVar++; return; } } if (O.IsWrite) NumInstrumentedWrites++; else NumInstrumentedReads++; unsigned Granularity = 1 << Mapping.Scale; if (O.MaybeMask) { // Masked Vector 関連の場合 instrumentMaskedLoadOrStore(this, DL, IntptrTy, O.MaybeMask, O.MaybeEVL, O.MaybeStride, O.getInsn(), Addr, O.Alignment, Granularity, O.OpType, O.IsWrite, nullptr, UseCalls, Exp); } else { // interesting な命令 O の直前にメモリアクセス検証処理を計装する doInstrumentAddress(this, O.getInsn(), O.getInsn(), Addr, O.Alignment, Granularity, O.TypeStoreSize, O.IsWrite, nullptr, UseCalls, Exp); } }
instrumentMop
はオプションで最適化 (検証が不要な箇所の計装除外) を行い、それ以外を計装していきます。
検証が不要かどうかは、isSafeAccess
関数で判定されます。
// isSafeAccess returns true if Addr is always inbounds with respect to its // base object. For example, it is a field access or an array access with // constant inbounds index. bool AddressSanitizer::isSafeAccess(ObjectSizeOffsetVisitor &ObjSizeVis, Value *Addr, TypeSize TypeStoreSize) const { if (TypeStoreSize.isScalable()) // TODO: We can use vscale_range to convert a scalable value to an // upper bound on the access size. return false; SizeOffsetAPInt SizeOffset = ObjSizeVis.compute(Addr); // Addr がどのオブジェクトからのオフセットか取得 if (!SizeOffset.bothKnown()) return false; uint64_t Size = SizeOffset.Size.getZExtValue(); // オブジェクトのサイズ int64_t Offset = SizeOffset.Offset.getSExtValue(); // オブジェクトの先頭アドレスからのオフセット // Three checks are required to ensure safety: // . Offset >= 0 (since the offset is given from the base ptr) // . Size >= Offset (unsigned) // . Size - Offset >= NeededSize (unsigned) return Offset >= 0 && Size >= uint64_t(Offset) && Size - uint64_t(Offset) >= TypeStoreSize / 8; }
基本的には、オブジェクトに対して固定オフセットで正当な領域へアクセスしている場合に true
を返します。
さて、長くなりましたが、以上が instrumentMop
の全体像です。
最後に、メモリアクセス時に挿入される、__asan_store
などの計装コードを見てみましょう。
これらの関数は、asan_rtl.cpp
で定義されています。
#define ASAN_MEMORY_ACCESS_CALLBACK_BODY(type, is_write, size, exp_arg, fatal) \ uptr sp = MEM_TO_SHADOW(addr); \ // シャドウメモリのアドレスを取得 ((addr >> Scale) + Offset のマクロ) uptr s = size <= ASAN_SHADOW_GRANULARITY ? *reinterpret_cast<u8 *>(sp) \ // シャドウメモリの中身を取得 : *reinterpret_cast<u16 *>(sp); \ if (UNLIKELY(s)) { \ // s が 0 ではない ⇒ アクセス不可領域にアクセスしようとしている可能性 if (UNLIKELY(size >= ASAN_SHADOW_GRANULARITY || \ // size 分のアクセス範囲にアクセス不可領域があるなら検知 ((s8)((addr & (ASAN_SHADOW_GRANULARITY - 1)) + size - 1)) >= \ (s8)s)) { \ ReportGenericErrorWrapper(addr, is_write, size, exp_arg, fatal); \ } \ } #define ASAN_MEMORY_ACCESS_CALLBACK(type, is_write, size) \ extern "C" NOINLINE INTERFACE_ATTRIBUTE \ void __asan_##type##size(uptr addr) { \ ASAN_MEMORY_ACCESS_CALLBACK_BODY(type, is_write, size, 0, true) \ } \ extern "C" NOINLINE INTERFACE_ATTRIBUTE \ void __asan_exp_##type##size(uptr addr, u32 exp) { \ ASAN_MEMORY_ACCESS_CALLBACK_BODY(type, is_write, size, exp, true) \ } \ extern "C" NOINLINE INTERFACE_ATTRIBUTE \ void __asan_##type##size ## _noabort(uptr addr) { \ ASAN_MEMORY_ACCESS_CALLBACK_BODY(type, is_write, size, 0, false) \ } \ ASAN_MEMORY_ACCESS_CALLBACK(load, false, 1) // __asan_(exp_)load1(_noabort) ASAN_MEMORY_ACCESS_CALLBACK(load, false, 2) // __asan_(exp_)load2(_noabort) ASAN_MEMORY_ACCESS_CALLBACK(load, false, 4) // __asan_(exp_)load4(_noabort) ASAN_MEMORY_ACCESS_CALLBACK(load, false, 8) // __asan_(exp_)load8(_noabort) ASAN_MEMORY_ACCESS_CALLBACK(load, false, 16) // __asan_(exp_)load16(_noabort) ASAN_MEMORY_ACCESS_CALLBACK(store, true, 1) // __asan_(exp_)store1(_noabort) ASAN_MEMORY_ACCESS_CALLBACK(store, true, 2) // __asan_(exp_)store2(_noabort) ASAN_MEMORY_ACCESS_CALLBACK(store, true, 4) // __asan_(exp_)store4(_noabort) ASAN_MEMORY_ACCESS_CALLBACK(store, true, 8) // __asan_(exp_)store8(_noabort) ASAN_MEMORY_ACCESS_CALLBACK(store, true, 16) // __asan_(exp_)store16(_noabort) extern "C" NOINLINE INTERFACE_ATTRIBUTE void __asan_loadN(uptr addr, uptr size) { if ((addr = __asan_region_is_poisoned(addr, size))) { // 指定されたサイズ分汚染されていないか検証する GET_CALLER_PC_BP_SP; ReportGenericError(pc, bp, sp, addr, false, size, 0, true); } } extern "C" NOINLINE INTERFACE_ATTRIBUTE void __asan_exp_loadN(uptr addr, uptr size, u32 exp) { if ((addr = __asan_region_is_poisoned(addr, size))) { GET_CALLER_PC_BP_SP; ReportGenericError(pc, bp, sp, addr, false, size, exp, true); } } extern "C" NOINLINE INTERFACE_ATTRIBUTE void __asan_loadN_noabort(uptr addr, uptr size) { if ((addr = __asan_region_is_poisoned(addr, size))) { GET_CALLER_PC_BP_SP; ReportGenericError(pc, bp, sp, addr, false, size, 0, false); } } extern "C" NOINLINE INTERFACE_ATTRIBUTE void __asan_storeN(uptr addr, uptr size) { if ((addr = __asan_region_is_poisoned(addr, size))) { GET_CALLER_PC_BP_SP; ReportGenericError(pc, bp, sp, addr, true, size, 0, true); } } extern "C" NOINLINE INTERFACE_ATTRIBUTE void __asan_exp_storeN(uptr addr, uptr size, u32 exp) { if ((addr = __asan_region_is_poisoned(addr, size))) { GET_CALLER_PC_BP_SP; ReportGenericError(pc, bp, sp, addr, true, size, exp, true); } } extern "C" NOINLINE INTERFACE_ATTRIBUTE void __asan_storeN_noabort(uptr addr, uptr size) { if ((addr = __asan_region_is_poisoned(addr, size))) { GET_CALLER_PC_BP_SP; ReportGenericError(pc, bp, sp, addr, true, size, 0, false); } }
この箇所で、__asan_exp_load1_noabort
などの関数が定義されています。
計装関数の命名規則は以下の通りです。
__asan_
: 検証関数の接頭辞exp_
: 最適化エクスペリエンス (ASan 自体のデバッグやテストで使うもの。ユーザーはまず使わない)load
/store
: 読み込み/書き込み1/2/4/8/16/N
: 検証するバイト数。N
は任意バイト数_noabort
: エラーを検知しても強制終了しない
N
以外の関数はマクロで定義されているようにシャドウメモリの取得と、いくつかの演算だけでアクセスを検証するため、高速です。
N
は任意バイト数へのアクセス検証を実現するためにポインタペアの比較でも出てきた __asan_region_is_poisoned
でアクセスを検証しているため、比較的低速です。
おわりに
今回は ASan がどのように実装されているか、関数の計装を取り上げて解説しました。
このように、LLVM の ASan は LLVM Pass を用いて計装されます。 メモリアクセス命令を抽出して、その直前にメモリアクセスを検証する計装コードを入れることで、不正な領域へのアクセスを検知しています。
FFRIセキュリティではサイバーセキュリティに関する興味関心を持つエンジニアを募集しています。 採用に関しては採用ページをご覧ください。