Hikari Source Analysis — AntiClassDump

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.


Hikari Source Analysis — AntiClassDump

My previous writeup summarized the problems I hit during obfuscation and the solution I settled on. Now I’m going to spend the next few posts walking through the implementation details and principles of the individual passes. I won’t rehash basic control-flow faking, bogus control flow, or byte substitution — those already have plenty of mature writeups. This series focuses on how Hikari implements anti-class-dump, anti-debug, anti-hook, and so on. This post dissects the implementation of AntiClassDump. The source below comes from https://github.com/61bcdefg/Hikari-LLVM15 — thanks to the original Hikari authors and contributors.

1. Prerequisites

1.1 Instance methods and class methods

In Objective-C, a method beginning with - is an instance method. It belongs to one or more instances of a class, and the class must be instantiated before it can be used.

A method beginning with + is a class method. Class methods in ObjC are analogous to static methods in Java — they belong to the class itself, don’t require instantiation, and can be called via the class name.

Looking at a decompiled Objective-C project in Hopper, and cross-referencing the source with the struct __objc_data, struct __objc_method, and struct __objc_method_list structures, you can see:

  • Instance methods (-) map to the method_list in the data field of _OBJC_CLASS_$_XXX;
  • Class methods (+) map to the method_list in the data field of _OBJC_METACLASS_$_XXX.

1.2 +initialize and +load

Both +initialize and +load are NSObject initializers, and both are called in base-class → subclass order. The difference is that +load is invoked when the class is added to the runtime, while +initialize is invoked when the class receives its first message.

1.3 Objective-C Categories

A Category adds methods to an existing class. Category declaration and implementation files are conventionally named OriginalClassName+CategoryName. The code below defines a category MyAddition that adds printNameAddition to MyClass:

@interface MyClass : NSObject
- (void)printName;
@end

@interface MyClass(MyAddition)
- (void)printNameAddition;
@end

Implementing those methods and compiling to bitcode assembly shows a few extra structures:

@"\01l_OBJC_$_CATEGORY_INSTANCE_METHODS_MyClass_$_MyAddition" = ...
@"\01l_OBJC_$_CATEGORY_MyClass_$_MyAddition" =
    private global %struct._category_t { ... }, section "__DATA, __objc_const", align 8
...
@"OBJC_LABEL_CATEGORY_$" = private global ...

The global @"\01l_OBJC_$_CATEGORY_MyClass_$_MyAddition" is a struct _category_t, defined as:

struct _category_t {
  const char * const name; // CategoryName
  struct _class_t *const cls;  // the class to be extended
  const struct _method_list_t * const instance_methods;
  const struct _method_list_t * const class_methods;
  const struct _protocol_list_t * const protocols;
  const struct _prop_list_t * const properties;
  const struct _prop_list_t * const class_properties;
  const uint32_t size;
}

Compiling to a binary and inspecting it, the name MyAddition is gone — printNameAddition() now lives in MyClass’s method list. Category methods are placed at the front of the list, and since method lookup is sequential, a category method “overrides” a same-named method on the original class.

2. How it works

At a high level, the pass inserts code into each Objective-C class’s initializer to defeat decompilation. The rough steps:

  1. Iterate over the module’s Objective-C classes and get each class’s method list.
  2. Generate an initializer function for each class, inserting code that protects the class’s information from leaking.
  3. Inside the initializer, insert code that raises the bar for decompilation and analysis, for example:
  • Renaming methods: replace a method’s implementation and give it a new name, so decompilers encounter renamed methods and the reading/understanding cost goes up.
  • Modifying method implementations: inject useless code fragments or apply obfuscation so decompilers can’t recover the original logic.
  • Adding code-verification logic: insert checks over parameters and return values to frustrate reverse analysis.
  • Using compiler security features: Apple’s ptrauth and opaque pointers make function/class pointers harder to crack and tamper with.
  1. Via the LLVM API, emit the corresponding code into the initializer and swap the modified implementation in for the original.
  2. The inserted code can be customized per requirements to raise decompilation difficulty and protect the code.

In short, inserting code into the Objective-C class initializer raises the decompilation difficulty, making it harder to recover the original logic and structure.

3. Code analysis

3.0 doInitialization

doInitialization is the pass’s initialization hook. It does the following:

  1. Fetch and check the target triple. It reads the module’s target triple (architecture, vendor, OS) into triple, then checks whether it represents an Apple architecture, since this pass is Apple-Objective-C-specific.

    triple = Triple(M.getTargetTriple());
    if (triple.getVendor() != Triple::VendorType::Apple) {
      // Only Apple's ObjC implementation is supported; warn for GNU Step etc.
      // ...
      return false;
    }
  2. Define the base type. It defines an LLVM pointer-to-int8 type (Int8PtrTy), used later in the Objective-C runtime function declarations.

    Type *Int8PtrTy = Type::getInt8PtrTy(M.getContext());
  3. Add Objective-C runtime function declarations. It builds the types of various Objective-C runtime functions and uses them to declare those functions, so they can be inserted or modified later.

    For example, it declares class_replaceMethod (replace a class method) and sel_registerName (register a selector).

    // Declare ObjC runtime functions
        FunctionType *IMPType =
            FunctionType::get(Int8PtrTy, {Int8PtrTy, Int8PtrTy}, true);
        PointerType *IMPPointerType = PointerType::getUnqual(IMPType);
        FunctionType *class_replaceMethod_type = FunctionType::get(
            IMPPointerType, {Int8PtrTy, Int8PtrTy, IMPPointerType, Int8PtrTy},
            false);
        M.getOrInsertFunction("class_replaceMethod", class_replaceMethod_type);
        FunctionType *sel_registerName_type =
            FunctionType::get(Int8PtrTy, {Int8PtrTy}, false);
        M.getOrInsertFunction("sel_registerName", sel_registerName_type);
    // ...
  4. Check feature support. Finally, it checks whether the module supports Apple pointer authentication (appleptrauth) and whether the LLVM context supports opaque pointers (opaquepointers). These two booleans steer the pass’s later behavior, especially around pointer and type handling.

      // Check ApplePtrauth and Opaque Pointers support
    appleptrauth = hasApplePtrauth(&M);
    opaquepointers = !M.getContext().supportsTypedPointers();
  5. Return value. Returns true on success; on a non-Apple architecture it prints an error and returns false.

In summary, doInitialization prepares the pass — confirming the target platform, declaring the needed runtime functions, and checking feature support so the pass can correctly process Objective-C code.

3.1 runOnModule

runOnModule is the generic pass framework entry point. Its logic:

  1. Get the OBJC_LABEL_CLASS_$ global. Tries to obtain the global variable OBJC_LABEL_CLASS_$, which holds Objective-C class info.

    GlobalVariable *OLCGV = M.getGlobalVariable("OBJC_LABEL_CLASS_$", true);
  2. Check the global. If not found, prints an error and returns false.

    if (!OLCGV) {
      errs() << "No ObjC Class Found in :" << M.getSourceFileName() << "\n";
       // No ObjC class found; the pass does nothing
      return false;
    }
  3. Get the global’s initializer. Asserts the global has an initializer and casts it to a constant array OBJC_LABEL_CLASS_CDS.

    assert(OLCGV->hasInitializer() &&
               "OBJC_LABEL_CLASS_$ Doesn't Have Initializer.");
              
    // Get OBJC_LABEL_CLASS_$'s initializer: the constant array of all class info
    ConstantArray *OBJC_LABEL_CLASS_CDS =
            dyn_cast<ConstantArray>(OLCGV->getInitializer());
  4. Class info processing. Defines containers: readyclses (classes ready to process), tmpclses (temporary queue for classes with dependencies), dependency (class → superclass), and GVMapping (class name → global variable).

    std::vector<std::string> readyclses;  // classes ready to process
    std::deque<std::string> tmpclses; // temporary queue for dependency handling
    std::map<std::string /*class*/, std::string /*super class*/> dependency; // class → superclass
    std::map<std::string /*Class*/, GlobalVariable *> GVMapping; // class name → global variable
  5. Iterate over class info. Iterate OBJC_LABEL_CLASS_CDS, extracting each class’s name and superclass. Based on whether it has a superclass and whether that superclass is already initialized, it pushes the name into readyclses or tmpclses.

    for (...) {
      ...
      if (supclsName == "" || (SuperClassGV && !SuperClassGV->hasInitializer())) {
        readyclses.emplace_back(clsName); // no/unitialized superclass → ready
      } else {
        tmpclses.emplace_back(clsName); // has superclass → defer
      }
    }
  6. Resolve class dependencies in order. A loop sorts classes by the dependency map so parents are processed before children.

     // Sort by dependency so parents are processed before children
    while (tmpclses.size()) {
      std::string clstmp = tmpclses.front();
      tmpclses.pop_front();
      std::string SuperClassName = dependency[clstmp];
      if (SuperClassName != "" &&
          std::find(readyclses.begin(), readyclses.end(), SuperClassName) ==
              readyclses.end()) {
        // Superclass not processed yet; requeue
        tmpclses.emplace_back(clstmp);
      } else {
        // Superclass processed; mark ready
        readyclses.emplace_back(clstmp);
      }
    }
  7. Process each class. Calls handleClass for each class in readyclses.

    for (std::string className : readyclses) {
      handleClass(GVMapping[className], &M);
    }
  8. Return value. Returns true.

To summarize, runOnModule processes every Objective-C class in the module: it finds the global holding the class definitions, iterates all classes, establishes a processing order based on parent-before-child, and processes each in that order.

3.2 handleClass

Now the core — handleClass, which processes a single Objective-C class. Step by step:

  1. Initializer check. Confirms the GlobalVariable GV (an Objective-C class) has an initializer; otherwise the assert fails.

    // If the class global has no initializer, bail
    assert(GV->hasInitializer() &&
           "ObjC Class Structure's Initializer Missing");
  2. Extract class and superclass names. Reads GV’s initializer (a ConstantStruct), then extracts the class name and superclass name for logging and later processing.

    ConstantStruct *CS = dyn_cast<ConstantStruct>(GV->getInitializer());
    StringRef ClassName = GV->getName();
    ClassName = ClassName.substr(strlen("OBJC_CLASS_$_"));
    StringRef SuperClassName =
        readPtrauth(
            cast<GlobalVariable>(CS->getOperand(1)->stripPointerCasts()))
            ->getName();
    SuperClassName = SuperClassName.substr(strlen("OBJC_CLASS_$_"));
    errs() << "Handling Class:" << ClassName
           << " With SuperClass:" << SuperClassName << "\n";
  3. Extract metaclass and read-only class struct. Extracts the metaclass (metaclassGV) and the read-only class info (class_ro), which may contain the method list.

    GlobalVariable *metaclassGV = readPtrauth(
        cast<GlobalVariable>(CS->getOperand(0)->stripPointerCasts()));
    GlobalVariable *class_ro = readPtrauth(
        cast<GlobalVariable>(CS->getOperand(4)->stripPointerCasts()));
    assert(metaclassGV->hasInitializer() && "MetaClass GV Initializer Missing");
    GlobalVariable *metaclass_ro = readPtrauth(cast<GlobalVariable>(
        metaclassGV->getInitializer()
            ->getOperand(metaclassGV->getInitializer()->getNumOperands() - 1)
            ->stripPointerCasts()));
  4. Method list check. If a method list is found in the metaclass, iterate it and extract each method’s name, type, and implementation.

    If a method is named initialize or load, an existing initializer is present, and its entry basic block (EntryBB) is recorded.

    if (Info.find("METHODLIST") != Info.end()) {
      ConstantArray *method_list = cast<ConstantArray>(Info["METHODLIST"]);
      for (unsigned i = 0; i < method_list->getNumOperands(); i++) {
        ConstantStruct *methodStruct =
            cast<ConstantStruct>(method_list->getOperand(i));
        // Extract the method's name, type, and implementation
        GlobalVariable *SELNameGV = cast<GlobalVariable>(
            opaquepointers ? methodStruct->getOperand(0)
                           : methodStruct->getOperand(0)->getOperand(0));
        ConstantDataSequential *SELNameCDS =
            cast<ConstantDataSequential>(SELNameGV->getInitializer());
        StringRef selname = SELNameCDS->getAsCString();
        if ((selname == "initialize" && UseInitialize) ||
            (selname == "load" && !UseInitialize)) {
          // Found an existing initializer (initialize or load); record its entry block
          Function *IMPFunc = cast<Function>(readPtrauth(cast<GlobalVariable>(
              methodStruct->getOperand(2)->stripPointerCasts())));
          errs() << "Found Existing initializer\n";
          EntryBB = &(IMPFunc->getEntryBlock());
        }
      }
    } else {
      errs() << "Didn't Find ClassMethod List\n";
    }
  5. Create the initializer method. If no existing initializer was found, it creates a new function and sets its block as EntryBB.

    if (!EntryBB) {
        FunctionType *InitializerType = FunctionType::get(
            Type::getVoidTy(M->getContext()), ArrayRef<Type *>(), false);
        Function *Initializer = Function::Create(
            InitializerType, GlobalValue::LinkageTypes::PrivateLinkage,
            "AntiClassDumpInitializer", M);
        EntryBB = BasicBlock::Create(M->getContext(), "", Initializer);
        ReturnInst::Create(M->getContext(), EntryBB);
    }
  6. Initialize the IRBuilder. Uses the found/created EntryBB to set up an IRBuilder<> for emitting LLVM IR.

    IRBuilder<> *IRB = new IRBuilder<>(EntryBB, EntryBB->getFirstInsertionPt());
  7. Prepare Objective-C API definitions. Gets the objc_getClass declaration for later use.

    // We now prepare ObjC API Definitions
    Function *objc_getClass = M->getFunction("objc_getClass");
    // End of ObjC API Definitions
    Value *ClassNameGV = IRB->CreateGlobalStringPtr(ClassName);
    // Now Scan For Props and Ivars in OBJC_CLASS_RO AND OBJC_METACLASS_RO
    // Note that class_ro_t's structure is different for 32 and 64bit runtime
    CallInst *Class = IRB->CreateCall(objc_getClass, {ClassNameGV});
  8. Handle the class and metaclass method lists. Calls HandleMethods for the metaclass and class method lists. If the method list is non-empty, it processes instance methods (for the metaclass) and class methods (for the class).

    // Add Methods
    ConstantStruct *metaclassCS =
        cast<ConstantStruct>(class_ro->getInitializer());
    ConstantStruct *classCS =
        cast<ConstantStruct>(metaclass_ro->getInitializer());
    if (!metaclassCS->getAggregateElement(5)->isNullValue()) {
      errs() << "Handling Instance Methods For Class:" << ClassName << "\n";
      HandleMethods(metaclassCS, IRB, M, Class, false);
    
      // ...
    }
    
    // Continue with instance and class methods
    // Only class methods are handled here; instance methods are similar
    GlobalVariable *methodListGV = nullptr; // global of the class method list
    if (!classCS->getAggregateElement(5)->isNullValue()) {
      errs() << "Handling Class Methods For Class:" << ClassName << "\n";
      HandleMethods(classCS, IRB, M, Class, true);
      methodListGV = readPtrauth(cast<GlobalVariable>(
          classCS->getAggregateElement(5)->stripPointerCasts()));
    }
  9. Create the new method list. Builds the new method struct and method list.

    // Define the method type
    Type *objc_method_type =
        StructType::getTypeByName(M->getContext(), "struct._objc_method");
    
    // Create the new method struct
    Constant *newMethod = ConstantStruct::get(
        cast<StructType>(objc_method_type),
        ArrayRef<Constant *>(methodStructContents)); // objc_method_t
    
    // Create the new method list
    ArrayType *AT = ArrayType::get(objc_method_type, 1);
    Constant *newMethodList = ConstantArray::get(
        AT, ArrayRef<Constant *>(newMethod)); // Container of objc_method_t
  10. Update the class method mapping. Adds the new method list into the class struct:

    //1. Create the struct type and instance for the new method list
    std::vector<Type *> newStructType;
    std::vector<Constant *> newStructValue;
    // ... (initialization of newStructType/newStructValue elided)
    
    StructType *newType = StructType::get(M->getContext(), ArrayRef<Type *>(newStructType));
    Constant *newMethodStruct = ConstantStruct::get(newType, ArrayRef<Constant *>(newStructValue)); // l_OBJC_$_CLASS_METHODS_
    
    
    //2. Create and register the new global
    GlobalVariable *newMethodStructGV = new GlobalVariable(
        *M, newType, true, GlobalValue::LinkageTypes::PrivateLinkage,
        newMethodStruct, "ACDNewClassMethodMap");
    appendToCompilerUsed(*M, {newMethodStructGV});
    
    //3. Replace the old method list reference and delete the old global:
    /**
    ConstantExpr::getBitCast and replaceAllUsesWith safely bitcast the new method
    list to the needed type and replace the old list, updating the class struct.
    dropAllReferences and eraseFromParent remove the old global from the module,
    avoiding leaks or other issues.
    */
    Constant *bitcastExpr = ConstantExpr::getBitCast(
        newMethodStructGV, PointerType::getUnqual(StructType::getTypeByName(
                               M->getContext(), "struct.__method_list_t")));
    
    opaquepointers ? classCS->setOperand(5, bitcastExpr)
                   : classCS->handleOperandChange(
                         classCS->getAggregateElement(5), bitcastExpr);
    
    if (methodListGV) {
        methodListGV->replaceAllUsesWith(ConstantExpr::getBitCast(
            newMethodStructGV,
            methodListGV->getType()));
        methodListGV->dropAllReferences();
        methodListGV->eraseFromParent();
    }

This function validates the class struct, extracts class info, processes the method list, creates the new method struct, and updates the class’s method mapping via a bitcast to the required type — replacing the old method list pointer in the class struct.

3.3 HandleMethods

Finally, HandleMethods handles the method list of an Objective-C class (or metaclass) and can replace existing method implementations. Step by step:

  1. Get the Objective-C runtime functions. Fetches the runtime function pointers used to register selectors, replace methods, and get class names/metaclasses.
// Get function pointers: sel_registerName, class_replaceMethod, class_getName, objc_getMetaClass
Function *sel_registerName = M->getFunction("sel_registerName");
Function *class_replaceMethod = M->getFunction("class_replaceMethod");
Function *class_getName = M->getFunction("class_getName");
Function *objc_getMetaClass = M->getFunction("objc_getMetaClass");
  1. Get the objc_method_list_t type.
StructType *objc_method_list_t_type =
    StructType::getTypeByName(M->getContext(), "struct.__method_list_t");
  1. Iterate the class struct, skipping null elements. Walks class_ro’s elements looking for the method list, skipping nulls.
for (unsigned int i = 0; i < class_ro->getType()->getNumElements(); i++) {
  Constant *tmp = dyn_cast<Constant>(class_ro->getAggregateElement(i));
  if (tmp->isNullValue()) {
    continue; // skip null elements
  }
  // ...
}
  1. Check and handle the method list. Checks whether the current element is the method-list type and handles it.
Type *type = tmp->getType();
if ((type == PointerType::getUnqual(objc_method_list_t_type)) ||
    (tmp->getName().startswith("_OBJC_$_INSTANCE_METHODS") ||
     tmp->getName().startswith("_OBJC_$_CLASS_METHODS"))) {
  // Handle the method list
  // ...
}
  1. Extract and validate the method list. Gets the method-list struct and validates it. Empty lists are skipped.
// Read the global to get methodListGV
GlobalVariable *methodListGV =
    readPtrauth(cast<GlobalVariable>(tmp->stripPointerCasts()));
// Assert the method-list global has an initializer; otherwise return
assert(methodListGV->hasInitializer() &&
       "MethodListGV doesn't have initializer");
// Get the method-list struct
ConstantStruct *methodListStruct =
    cast<ConstantStruct>(methodListGV->getInitializer());
  1. Iterate the methods. For each method, methodStruct holds the method name, type signature, and implementation (IMP).
// Extract methodList from the method-list struct
ConstantArray *methodList = cast<ConstantArray>(methodListStruct->getOperand(2));
// Iterate methodList
for (unsigned int i = 0; i < methodList->getNumOperands(); i++) {
  // Get methodStruct
  ConstantStruct *methodStruct = cast<ConstantStruct>(methodList->getOperand(i));
  // ...
}
  1. Extract and register the selector. For each method, extracts the name and registers its selector (SEL) via sel_registerName — the key that identifies the method in ObjC message dispatch.
// Extract the method name
Constant *SELName = IRB->CreateGlobalStringPtr(
    cast<ConstantDataSequential>(
        cast<GlobalVariable>(
            opaquepointers
                ? methodStruct->getOperand(0)
                : cast<ConstantExpr>(methodStruct->getOperand(0))
                      ->getOperand(0))
            ->getInitializer())
        ->getAsCString());

// Register the SEL
CallInst *SEL = IRB->CreateCall(sel_registerName, {SELName});
  1. Extract and convert the method implementation. Extracts the method’s IMP and bitcasts it to the appropriate type.
// Extract the method implementation
Value *BitCastedIMP = IRB->CreateBitCast(
    appleptrauth
        ? opaquepointers
              ? cast<GlobalVariable>(methodStruct->getOperand(2))
                    ->getInitializer()
                    ->getOperand(0)
              : cast<ConstantExpr>(
                    cast<GlobalVariable>(methodStruct->getOperand(2))
                        ->getInitializer()
                        ->getOperand(0))
                    ->getOperand(0)
        : methodStruct->getOperand(2),
    IMPType);
  1. Prepare the class_replaceMethod arguments. Depending on whether this is a metaclass, uses class_getName + objc_getMetaClass, or the Class parameter directly, to build the argument list.
std::vector<Value *> replaceMethodArgs;
if (isMetaClass) {
  // For a metaclass, get the class name and call objc_getMetaClass
  CallInst *className = IRB->CreateCall(class_getName, {Class});
  CallInst *MetaClass =
      IRB->CreateCall(objc_getMetaClass, {className});
  replaceMethodArgs.emplace_back(MetaClass); // Class
} else {
  replaceMethodArgs.emplace_back(Class); // Class
}
replaceMethodArgs.emplace_back(SEL);          // SEL
replaceMethodArgs.emplace_back(BitCastedIMP); // imp
  1. Extract the method type. Extracts the method type from methodStruct and converts it to a global string pointer.
// Extract the method type
replaceMethodArgs.emplace_back(IRB->CreateGlobalStringPtr(
    cast<ConstantDataSequential>(
        cast<GlobalVariable>(
            opaquepointers
                ? methodStruct->getOperand(1)
                : cast<ConstantExpr>(methodStruct->getOperand(1))
                      ->getOperand(0))
            ->getInitializer())
        ->getAsCString())); // type
  1. Call class_replaceMethod. Replaces/registers the method implementation with the prepared arguments.
// Call class_replaceMethod to replace and register the method
IRB->CreateCall(class_replaceMethod, ArrayRef<Value *>(replaceMethodArgs));
  1. Optionally rename the method implementation. If RenameMethodIMP is enabled, renames the implementation to "ACDMethodIMP".
  if (RenameMethodIMP) {
  // Rename the method implementation if needed
  Function *MethodIMP = cast<Function>(
      appleptrauth
          ? opaquepointers
                ? cast<GlobalVariable>(methodStruct->getOperand(2))
                      ->getInitializer()
                      ->getOperand(0)
                : cast<ConstantExpr>(
                      cast<GlobalVariable>(
                          methodStruct->getOperand(2)->getOperand(0))
                          ->getInitializer()
                          ->getOperand(0))
                      ->getOperand(0)
      : opaquepointers ? methodStruct->getOperand(2)
                       : methodStruct->getOperand(2)->getOperand(0));
  MethodIMP->setName("ACDMethodIMP");
  }

Throughout HandleMethods, the code manipulates the Objective-C runtime to dynamically modify a class’s method list — building and emitting LLVM IR with IRBuilder, calling Objective-C runtime functions, and walking the method-list data in the class struct.

4. Summary

This post traced the logic function by function and listed the key code for each. It’s my own understanding — if anything is off or misleading, corrections are welcome. Next up: source analysis of anti-hook and anti-debug.


Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch