Android Runtime调试检测与反制手段(86)

Android Runtime调试检测与反制手段

一、调试检测概述

在Android Runtime(ART)中,调试检测与反制是保护应用安全的重要机制。随着移动应用安全威胁的不断增加,开发者需要采取措施防止应用被调试、逆向工程或篡改。ART提供了多种调试检测机制,包括调试器存在检测、内存完整性检查、代码注入检测等。这些机制通过系统调用、内存监控和安全标志位等方式实现,能够有效识别和阻止未经授权的调试活动。

从源码角度来看,调试检测涉及art/runtime目录下的多个核心模块。debug目录实现了调试器检测和调试状态管理,oat目录处理编译优化和代码混淆,security目录包含了签名验证和完整性检查机制。接下来,我们将深入分析每个关键步骤的原理与实现细节。

二、调试器存在检测

2.1 /proc/self/status检查

ART通过检查/proc/self/status文件来检测调试器是否存在。在art/runtime/debug/debugger.cc中,实现如下:

cpp 复制代码
// 检测调试器是否存在
bool Debugger::IsDebuggerAttached() {
    // 打开/proc/self/status文件
    FILE* file = fopen("/proc/self/status", "r");
    if (file == nullptr) {
        return false;
    }
    
    char line[256];
    bool result = false;
    
    // 逐行读取文件内容
    while (fgets(line, sizeof(line), file) != nullptr) {
        // 检查TracerPid字段
        if (strncmp(line, "TracerPid:", 10) == 0) {
            // 解析TracerPid值
            int tracer_pid = 0;
            sscanf(line, "TracerPid: %d", &tracer_pid);
            
            // 如果TracerPid不为0,表示有调试器附加
            if (tracer_pid != 0) {
                result = true;
            }
            break;
        }
    }
    
    // 关闭文件
    fclose(file);
    
    return result;
}

这段代码通过读取/proc/self/status文件中的TracerPid字段来判断是否有调试器附加到当前进程。如果TracerPid不为0,则表示有调试器正在调试该进程。

2.2 ptrace检测

ART还通过ptrace系统调用检测自身是否被调试。在art/runtime/os.cc中,实现如下:

cpp 复制代码
// 检测是否被ptrace调试
bool IsBeingTraced() {
    // 尝试对自身执行PTRACE_TRACEME操作
    if (ptrace(PTRACE_TRACEME, 0, 1, 0) == -1) {
        // 如果返回-1且errno为EPERM,表示已被其他进程ptrace
        if (errno == EPERM) {
            return true;
        }
    }
    
    // 恢复ptrace状态
    ptrace(PTRACE_DETACH, 0, 1, 0);
    
    return false;
}

这段代码通过尝试对自身执行PTRACE_TRACEME操作来检测是否已被其他进程ptrace。如果操作失败且错误码为EPERM,则表示已被调试。

三、调试状态检测

3.1 检查调试标志

ART通过检查进程的调试标志来判断是否处于调试模式。在art/runtime/runtime.cc中,实现如下:

cpp 复制代码
// 检查进程是否处于调试模式
bool Runtime::IsDebuggable() const {
    // 获取应用的ApplicationInfo
    ApplicationInfo* app_info = GetApplicationInfo();
    if (app_info == nullptr) {
        return false;
    }
    
    // 检查debuggable标志
    return app_info->debuggable;
}

这段代码通过检查应用的ApplicationInfo中的debuggable标志来判断应用是否被标记为可调试。该标志通常在AndroidManifest.xml中设置。

3.2 检查JDWP连接

ART通过检查JDWP(Java Debug Wire Protocol)连接来判断是否有调试器连接。在art/runtime/jdwp/jdwp.cc中,实现如下:

cpp 复制代码
// 检查是否有JDWP连接
bool Jdwp::IsJdwpConnected() {
    // 检查JDWP套接字是否打开
    if (jdwp_socket_ != -1) {
        // 检查套接字状态
        struct sockaddr_in addr;
        socklen_t len = sizeof(addr);
        if (getsockname(jdwp_socket_, (struct sockaddr*)&addr, &len) == 0) {
            // 如果套接字已绑定且处于监听状态,表示有JDWP连接
            if (addr.sin_port != 0) {
                return true;
            }
        }
    }
    
    return false;
}

这段代码通过检查JDWP套接字的状态来判断是否有调试器连接。如果套接字已绑定且处于监听状态,则表示有调试器连接。

四、内存完整性检查

4.1 代码段校验

ART通过计算代码段的哈希值来检测代码是否被修改。在art/runtime/oat/oat_file_manager.cc中,实现如下:

cpp 复制代码
// 校验OAT文件的完整性
bool OatFileManager::VerifyOatFileIntegrity(const OatFile* oat_file) {
    // 获取OAT文件的代码段
    const uint8_t* code_begin = oat_file->GetExecutableCodeBegin();
    size_t code_size = oat_file->GetExecutableCodeSize();
    
    // 计算当前代码段的哈希值
    uint64_t current_hash = CalculateHash(code_begin, code_size);
    
    // 获取预计算的哈希值
    uint64_t expected_hash = oat_file->GetExpectedCodeHash();
    
    // 比较哈希值
    return current_hash == expected_hash;
}

// 计算内存区域的哈希值
uint64_t CalculateHash(const uint8_t* data, size_t size) {
    // 使用SHA-256算法计算哈希值
    SHA256_CTX sha256;
    SHA256_Init(&sha256);
    SHA256_Update(&sha256, data, size);
    
    uint8_t hash[SHA256_DIGEST_SIZE];
    SHA256_Final(hash, &sha256);
    
    // 将哈希值转换为64位整数
    uint64_t result = 0;
    for (int i = 0; i < 8; i++) {
        result |= ((uint64_t)hash[i]) << (i * 8);
    }
    
    return result;
}

这段代码通过计算OAT文件代码段的哈希值并与预计算的哈希值比较,来检测代码是否被修改。如果哈希值不匹配,则表示代码可能已被篡改。

4.2 关键变量监控

ART监控关键变量的内存地址和值,检测是否被调试器修改。在art/runtime/thread.cc中,实现如下:

cpp 复制代码
// 初始化关键变量监控
void Thread::InitCriticalVariableMonitoring() {
    // 记录关键变量的初始值和地址
    critical_variables_.push_back({
        &exception_,              // 异常指针
        reinterpret_cast<uintptr_t>(&exception_),
        reinterpret_cast<uintptr_t>(exception_)
    });
    
    critical_variables_.push_back({
        &tls_ptr_,                // TLS指针
        reinterpret_cast<uintptr_t>(&tls_ptr_),
        reinterpret_cast<uintptr_t>(tls_ptr_)
    });
    
    // 启动监控线程
    StartMonitoringThread();
}

// 检查关键变量是否被修改
bool Thread::CheckCriticalVariables() {
    for (const auto& var : critical_variables_) {
        // 获取当前变量的地址和值
        uintptr_t current_addr = reinterpret_cast<uintptr_t>(var.ptr);
        uintptr_t current_value = 0;
        
        if (var.type == kPointer) {
            current_value = reinterpret_cast<uintptr_t>(*reinterpret_cast<void**>(var.ptr));
        } else if (var.type == kInt32) {
            current_value = *reinterpret_cast<int32_t*>(var.ptr);
        }
        
        // 比较当前值与初始值
        if (current_addr != var.initial_addr || current_value != var.initial_value) {
            // 变量被修改,可能存在调试活动
            return false;
        }
    }
    
    return true;
}

这段代码通过记录关键变量的初始值和地址,并定期检查这些值是否被修改,来检测调试活动。

五、代码混淆与反调试

5.1 控制流平坦化

ART使用控制流平坦化技术来混淆代码,增加调试难度。在art/runtime/optimizer/control_flow_flattening.cc中,实现如下:

cpp 复制代码
// 执行控制流平坦化
bool ControlFlowFlattening::Run() {
    // 构建控制流图
    BuildControlFlowGraph();
    
    // 识别基本块
    IdentifyBasicBlocks();
    
    // 创建分发器
    CreateDispatcher();
    
    // 平坦化控制流
    FlattenControlFlow();
    
    // 优化分发器
    OptimizeDispatcher();
    
    return true;
}

// 平坦化控制流
void ControlFlowFlattening::FlattenControlFlow() {
    // 为每个基本块分配一个ID
    std::unordered_map<BasicBlock*, int> block_ids;
    int next_id = 0;
    
    for (auto* block : graph_->GetBlocks()) {
        if (block != nullptr && !block->IsExitBlock()) {
            block_ids[block] = next_id++;
        }
    }
    
    // 创建状态变量
    HInstruction* state_var = new (graph_->GetArena()) HInt32Constant(0);
    entry_block_->AddInstruction(state_var);
    
    // 修改每个基本块的结尾
    for (auto* block : graph_->GetBlocks()) {
        if (block != nullptr && !block->IsExitBlock()) {
            // 保存当前状态
            HInstruction* save_state = new (graph_->GetArena()) HStoreState(state_var, block_ids[block]);
            block->AddInstructionBefore(save_state, block->GetLastInstruction());
            
            // 跳转到分发器
            HInstruction* jump_to_dispatcher = new (graph_->GetArena()) HGoto(dispatcher_block_);
            block->AddInstruction(jump_to_dispatcher);
        }
    }
    
    // 构建分发器逻辑
    BuildDispatcherLogic(block_ids);
}

这段代码通过将程序的控制流转换为基于状态机的结构,使调试器难以跟踪程序的执行路径,从而增加逆向工程的难度。

5.2 指令替换与加密

ART使用指令替换和加密技术来保护关键代码段。在art/runtime/optimizer/instruction_rewriter.cc中,实现如下:

cpp 复制代码
// 执行指令替换
bool InstructionRewriter::RewriteInstructions() {
    // 遍历所有基本块
    for (auto* block : graph_->GetBlocks()) {
        if (block == nullptr) {
            continue;
        }
        
        // 遍历基本块中的所有指令
        for (auto it = block->GetInstructions().begin();
             it != block->GetInstructions().end(); ++it) {
            HInstruction* instruction = *it;
            
            // 根据指令类型进行替换
            switch (instruction->GetType()) {
                case HInstruction::kAdd:
                    RewriteAddInstruction(block, it);
                    break;
                case HInstruction::kSub:
                    RewriteSubInstruction(block, it);
                    break;
                case HInstruction::kMul:
                    RewriteMulInstruction(block, it);
                    break;
                // 其他指令类型...
                default:
                    // 不替换的指令
                    break;
            }
        }
    }
    
    return true;
}

// 替换加法指令
void InstructionRewriter::RewriteAddInstruction(BasicBlock* block,
                                                InstructionList::iterator& it) {
    HAdd* add = it->AsAdd();
    
    // 创建一个随机数
    int32_t random = GenerateRandomNumber();
    
    // 替换 a + b 为 (a + random) + (b - random)
    HInstruction* a = add->InputAt(0);
    HInstruction* b = add->InputAt(1);
    
    HInstruction* a_plus_random = new (graph_->GetArena()) HAdd(a, new (graph_->GetArena()) HInt32Constant(random));
    HInstruction* b_minus_random = new (graph_->GetArena()) HSub(b, new (graph_->GetArena()) HInt32Constant(random));
    
    HInstruction* new_add = new (graph_->GetArena()) HAdd(a_plus_random, b_minus_random);
    
    // 替换原指令
    block->ReplaceInstruction(it, new_add);
}

这段代码通过将简单的指令替换为功能等价但更复杂的指令序列,使调试器难以理解代码的实际功能。

六、调试反制手段

6.1 调试器对抗

ART实现了多种调试器对抗技术,包括调试器检测后的主动反制。在art/runtime/debug/debugger.cc中,实现如下:

cpp 复制代码
// 检测到调试器后的反制措施
void Debugger::CounterDebugger() {
    // 记录调试事件
    RecordDebugEvent();
    
    // 选择反制策略
    CountermeasureStrategy strategy = SelectCountermeasureStrategy();
    
    // 执行反制措施
    switch (strategy) {
        case kExitProcess:
            // 直接退出进程
            ExitProcess();
            break;
        case kCrashProcess:
            // 使进程崩溃,破坏调试环境
            CrashProcess();
            break;
        case kInjectFaults:
            // 注入随机错误,干扰调试
            InjectFaults();
            break;
        case kSlowDown:
            // 减慢执行速度,增加调试难度
            SlowDownExecution();
            break;
        case kObfuscateMemory:
            // 混淆内存内容,干扰内存分析
            ObfuscateMemory();
            break;
        default:
            // 默认策略:什么也不做
            break;
    }
}

// 注入随机错误
void Debugger::InjectFaults() {
    // 随机选择是否注入错误
    if (rand() % 100 < 30) {  // 30%的概率注入错误
        // 随机选择错误类型
        int fault_type = rand() % 3;
        
        switch (fault_type) {
            case 0:
                // 抛出随机异常
                ThrowRandomException();
                break;
            case 1:
                // 返回错误结果
                ReturnErrorResult();
                break;
            case 2:
                // 死循环
                EnterInfiniteLoop();
                break;
        }
    }
}

这段代码展示了ART在检测到调试器后采取的反制措施,包括退出进程、注入错误、减慢执行速度等,以干扰和阻止调试活动。

6.2 内存保护与混淆

ART通过内存保护和混淆技术防止调试器读取和修改内存。在art/runtime/memory_protection.cc中,实现如下:

cpp 复制代码
// 保护关键内存区域
void MemoryProtection::ProtectCriticalRegions() {
    // 获取关键内存区域列表
    std::vector<MemoryRegion> critical_regions = GetCriticalRegions();
    
    // 为每个关键区域设置保护
    for (const auto& region : critical_regions) {
        // 设置内存区域为只读
        if (!region.Protect(PROT_READ)) {
            LOG(WARNING) << "Failed to protect memory region";
        }
        
        // 添加内存保护标记
        AddMemoryProtectionTag(region);
    }
}

// 混淆内存内容
void MemoryProtection::ObfuscateMemory() {
    // 获取需要混淆的内存区域
    std::vector<MemoryRegion> regions_to_obfuscate = GetRegionsToObfuscate();
    
    // 为每个区域生成随机密钥
    for (const auto& region : regions_to_obfuscate) {
        // 生成随机密钥
        uint8_t key[16];
        GenerateRandomKey(key, sizeof(key));
        
        // 加密内存区域
        EncryptMemoryRegion(region, key, sizeof(key));
        
        // 记录密钥和区域的关联
        RegisterObfuscatedRegion(region, key);
    }
}

// 访问混淆的内存
void* MemoryProtection::AccessObfuscatedMemory(const void* address) {
    // 查找地址所在的混淆区域
    MemoryRegion region = FindObfuscatedRegion(address);
    if (!region.IsValid()) {
        return nullptr;
    }
    
    // 获取区域的加密密钥
    uint8_t* key = GetKeyForRegion(region);
    if (key == nullptr) {
        return nullptr;
    }
    
    // 临时解密内存区域
    DecryptMemoryRegion(region, key);
    
    // 返回内存地址
    return region.pointer();
}

这段代码展示了ART如何保护和混淆关键内存区域,防止调试器直接读取和修改内存内容。

七、签名验证与完整性检查

7.1 APK签名验证

ART在加载APK时验证其签名,确保应用的完整性。在art/runtime/package_manager.cc中,实现如下:

cpp 复制代码
// 验证APK签名
bool PackageManager::VerifyApkSignature(const std::string& apk_path) {
    // 打开APK文件
    ZipFile zip_file;
    if (!zip_file.Open(apk_path.c_str())) {
        LOG(ERROR) << "Failed to open APK file: " << apk_path;
        return false;
    }
    
    // 获取APK的签名信息
    ApkSignatureInfo signature_info;
    if (!GetApkSignatureInfo(zip_file, &signature_info)) {
        LOG(ERROR) << "Failed to get APK signature info: " << apk_path;
        return false;
    }
    
    // 验证签名
    bool verified = VerifySignature(signature_info);
    
    // 关闭APK文件
    zip_file.Close();
    
    return verified;
}

// 获取APK签名信息
bool PackageManager::GetApkSignatureInfo(const ZipFile& zip_file,
                                        ApkSignatureInfo* signature_info) {
    // 查找META-INF目录下的签名文件
    std::vector<std::string> signature_files = FindSignatureFiles(zip_file);
    if (signature_files.empty()) {
        LOG(ERROR) << "No signature files found";
        return false;
    }
    
    // 读取签名文件内容
    for (const auto& file : signature_files) {
        std::vector<uint8_t> content;
        if (!zip_file.ReadFile(file, &content)) {
            LOG(ERROR) << "Failed to read signature file: " << file;
            continue;
        }
        
        // 解析签名信息
        if (!ParseSignatureFile(content, signature_info)) {
            LOG(ERROR) << "Failed to parse signature file: " << file;
            continue;
        }
    }
    
    return true;
}

// 验证签名
bool PackageManager::VerifySignature(const ApkSignatureInfo& signature_info) {
    // 获取应用的预期签名
    std::vector<uint8_t> expected_signature = GetExpectedSignature();
    
    // 比较实际签名和预期签名
    return signature_info.signature == expected_signature;
}

这段代码展示了ART如何验证APK的签名,确保应用没有被篡改。

7.2 运行时完整性检查

ART在运行时定期检查关键组件的完整性。在art/runtime/integrity_checker.cc中,实现如下:

cpp 复制代码
// 初始化完整性检查
void IntegrityChecker::Init() {
    // 记录关键组件的初始哈希值
    RecordInitialHashes();
    
    // 启动完整性检查线程
    StartIntegrityCheckThread();
}

// 记录初始哈希值
void IntegrityChecker::RecordInitialHashes() {
    // 获取关键组件列表
    std::vector<std::string> critical_components = GetCriticalComponents();
    
    // 计算每个组件的哈希值
    for (const auto& component : critical_components) {
        uint64_t hash = CalculateComponentHash(component);
        initial_hashes_[component] = hash;
    }
}

// 运行完整性检查
void IntegrityChecker::RunIntegrityCheck() {
    // 获取关键组件列表
    std::vector<std::string> critical_components = GetCriticalComponents();
    
    // 检查每个组件的完整性
    for (const auto& component : critical_components) {
        uint64_t current_hash = CalculateComponentHash(component);
        
        // 查找初始哈希值
        auto it = initial_hashes_.find(component);
        if (it == initial_hashes_.end()) {
            LOG(WARNING) << "Component not found in initial hashes: " << component;
            continue;
        }
        
        // 比较哈希值
        if (current_hash != it->second) {
            LOG(ERROR) << "Integrity check failed for component: " << component;
            HandleIntegrityViolation(component);
        }
    }
}

// 处理完整性违规
void IntegrityChecker::HandleIntegrityViolation(const std::string& component) {
    // 记录违规事件
    RecordIntegrityViolation(component);
    
    // 采取反制措施
    CountermeasureStrategy strategy = SelectCountermeasureStrategy();
    
    switch (strategy) {
        case kLogAndContinue:
            // 记录日志但继续运行
            break;
        case kRestartComponent:
            // 重启受影响的组件
            RestartComponent(component);
            break;
        case kRestartApp:
            // 重启整个应用
            RestartApplication();
            break;
        case kShutDown:
            // 关闭应用
            ShutdownApplication();
            break;
    }
}

这段代码展示了ART如何在运行时定期检查关键组件的完整性,确保应用没有被篡改或注入恶意代码。

八、调试检测的性能优化

8.1 延迟检测

ART使用延迟检测策略来减少调试检测对应用性能的影响。在art/runtime/debug/debugger.cc中,实现如下:

cpp 复制代码
// 延迟调试检测
void Debugger::LazyDebuggerDetection() {
    // 记录上次检测时间
    static time_t last_detection_time = 0;
    time_t current_time = time(nullptr);
    
    // 检查是否需要进行检测
    if (current_time - last_detection_time < kDetectionInterval) {
        return;
    }
    
    // 更新上次检测时间
    last_detection_time = current_time;
    
    // 执行调试检测
    if (IsDebuggerAttached()) {
        // 检测到调试器,采取反制措施
        CounterDebugger();
    }
}

这段代码展示了ART如何通过延迟检测来减少调试检测的频率,从而降低对应用性能的影响。

8.2 轻量级检测

ART实现了轻量级的调试检测方法,以减少性能开销。在art/runtime/debug/debugger.cc中,实现如下:

cpp 复制代码
// 轻量级调试检测
bool Debugger::LightweightDebuggerDetection() {
    // 快速检查常见的调试特征
    if (CheckForCommonDebugSignatures()) {
        // 发现调试特征,进行更详细的检测
        return IsDebuggerAttached();
    }
    
    // 未发现调试特征,返回false
    return false;
}

// 检查常见的调试特征
bool Debugger::CheckForCommonDebugSignatures() {
    // 检查环境变量
    if (GetEnvironmentVariable("DEBUGGER_PRESENT") != nullptr) {
        return true;
    }
    
    // 检查特定文件是否存在
    if (FileExists("/data/local/tmp/debug_marker")) {
        return true;
    }
    
    // 检查特定进程是否运行
    if (IsProcessRunning("gdbserver")) {
        return true;
    }
    
    // 检查特定端口是否打开
    if (IsPortOpen(8700)) {  // JDWP默认端口
        return true;
    }
    
    // 未发现常见调试特征
    return false;
}

这段代码展示了ART如何通过快速检查常见的调试特征来减少性能开销,只有在发现可疑特征时才进行更详细的检测。

九、调试检测的局限性与挑战

9.1 调试检测绕过技术

尽管ART实现了多种调试检测机制,但攻击者可以使用各种技术绕过这些检测。例如,攻击者可以:

  • 修改或hook系统调用,伪造调试状态
  • 使用root权限修改/proc/self/status文件内容
  • 使用反调试工具(如Frida、Xposed)来绕过检测逻辑
  • 静态分析和patch应用代码,移除调试检测逻辑

9.2 性能与安全的平衡

调试检测机制会带来一定的性能开销,特别是在频繁进行检测或使用复杂的检测算法时。开发者需要在性能和安全之间找到平衡,避免过度检测导致应用性能下降。例如,延迟检测和轻量级检测策略就是为了减少性能开销而设计的。

9.3 兼容性问题

某些调试检测技术可能与合法的开发和调试工具不兼容。例如,一些检测方法可能误报合法的调试会话,导致应用异常退出。开发者需要确保调试检测机制不会影响正常的开发和测试流程,同时仍能有效防止恶意调试活动。

十、调试检测的应用场景

10.1 保护敏感数据

调试检测机制可用于保护应用中的敏感数据,如用户密码、支付信息等。例如,在处理敏感数据前,应用可以检测是否存在调试器:

java 复制代码
// 敏感操作前检测调试器
public void performSensitiveOperation() {
    if (Debugger.isDebuggerConnected()) {
        // 检测到调试器,拒绝执行敏感操作
        throw new SecurityException("Debugger detected");
    }
    
    // 执行敏感操作
    processSensitiveData();
}

这种方式可以防止调试器捕获和分析敏感数据。

10.2 防止作弊和盗版

在游戏和金融应用中,调试检测可用于防止作弊和盗版。例如,游戏应用可以检测是否存在调试器,防止玩家使用调试工具修改游戏状态:

java 复制代码
// 游戏循环中检测调试器
public void gameLoop() {
    while (isGameRunning()) {
        // 检测调试器
        if (Debugger.isDebuggerConnected()) {
            // 检测到调试器,终止游戏
            terminateGame();
            return;
        }
        
        // 更新游戏状态
        updateGameState();
        
        // 渲染游戏画面
        renderGame();
        
        // 等待下一帧
        waitForNextFrame();
    }
}

这种方式可以保护游戏的公平性和商业利益。

十一、调试检测的未来发展趋势

11.1 基于机器学习的检测

未来的调试检测机制可能会结合机器学习技术,通过学习正常和异常的应用行为模式来检测调试活动。例如,使用深度学习模型分析应用的系统调用模式、内存访问模式等,识别潜在的调试行为。

11.2 硬件辅助安全

随着硬件技术的发展,未来的Android设备可能会提供更多的硬件辅助安全功能,如专用的安全处理器、内存加密引擎等。这些硬件功能可以增强调试检测的可靠性和性能,提供更强大的安全保障。

11.3 动态防御系统

未来的调试检测系统可能会发展为动态防御系统,能够根据实时威胁情况自动调整防御策略。例如,当检测到潜在的调试活动时,系统可以动态增加检测频率、改变检测方法或采取更激进的反制措施。

相关推荐
阿星做前端21 分钟前
聊聊前端请求拦截那些事
前端·javascript·面试
网安Ruler2 小时前
开发框架安全&ThinkPHP&Laravel&SpringBoot&Struts2&SpringCloud&复现
android
小码哥_常3 小时前
Android开发自救指南:当大图遇上OOM,这波操作能保命!
android·前端
小码哥_常3 小时前
Kotlin RecyclerView数据错乱大作战:从"鬼打墙"到丝滑大师
android·kotlin
红衣信3 小时前
useContext 与 useReducer 的组合使用
前端·react.js·面试
拉不动的猪3 小时前
针对初学者的JS八种类型实用小技巧总结
javascript·css·面试
3 小时前
Android本地浏览PDF(Android PDF.js 简要学习手册)
android·javascript·pdf
程序员小胡06194 小时前
操作系统系统面试常问(进程、线程、协程相关知识)
linux·面试·职场和发展
归于尽4 小时前
key、JSX、Babel编译、受控组件与非受控组件、虚拟DOM考点解析
前端·react.js·面试
远方2354 小时前
Android无需授权直接访问Android/data目录漏洞
android·安全·文件·漏洞·目录·权限