教程長篇分享

安卓逆向:簽名校驗對抗

學習吾愛破解論壇正己的《安卓逆向這檔事》的筆記,視頻和配套工具可以去課程主頁獲取:https://www.52pojie.cn/thread-1731181-1-1.html

工具

  1. 教程Demo
  2. MT管理器
  3. 雷電模擬器
  4. Jadx-gui
  5. 演算法助手

校驗

是開發者在數據傳送時採用的一種校正數據的一種方式。常見的校驗有:

  • 應用簽名校驗(最常見)
  • dex CRC 校驗
  • APK 完整性校驗
  • 路徑文件校驗

應用簽名校驗

應用簽名

通過對 Apk 進行簽名,開發者可以證明對 Apk 的所有權和控制權,可用於安裝和更新其應用。而在 Android 設備上的安裝 Apk ,如果是一個沒有被簽名的 Apk,則會被拒絕安裝。在安裝 Apk 的時候,軟體包管理器也會驗證 Apk 是否已經被正確簽名,並且通過簽名證書和數據摘要驗證是否合法沒有被篡改。只有確認安全無篡改的情況下,才允許安裝在設備上。

簡單來說,APK 簽名的主要作用有兩個:

  1. 證明 APK 的所有者。
  2. 允許 Android 市場和設備校驗 APK 的正確性。

Android 目前支持以下四種應用簽名方案:

  • v1 方案:基於 JAR 簽名。
  • v2 方案:APK 簽名方案 v2(在 Android 7.0 中引入)
  • v3 方案:APK 簽名方案 v3(在 Android 9 中引入)
  • v4 方案:APK 簽名方案 v4(在 Android 11 中引入)

V1 簽名

V1 簽名的機制主要就 META-INF 目錄下的三個文件:

  • MANIFEST.MF:這是摘要文件。程序遍歷Apk包中的所有文件(entry),對非文件夾非簽名文件的文件,逐個用 SHA1(安全哈希演算法)生成摘要信息,再用 Base64 進行編碼。如果你改變了 apk 包中的文件,那麼在 apk 安裝校驗時,改變後的文件摘要信息與 MANIFEST.MF 的檢驗信息不同,於是程序就不能成功安裝。

MANIFEST.MF

  • ANDROID.SF:這是對摘要的簽名文件。對前一步生成的 MANIFEST.MF,使用 SHA1-RSA 演算法,用開發者的私鑰進行簽名。在安裝時只能使用公鑰才能解密它。解密之後,將它與未加密的摘要信息(即,MANIFEST.MF 文件)進行對比,如果相符,則表明內容沒有被異常修改。

ANDROID.SF

  • ANDROID.RSA:文件中保存了公鑰、所採用的加密演算法等信息。

ANDROID.RSA

在某些情況下,直接對 apk 進行 V1 簽名可以繞過 apk 的簽名校驗。

V2 方案會將 APK 文件視為 blob,並對整個文件進行簽名檢查。對 APK 進行的任何修改(包括對 ZIP 元數據進行的修改)都會使 APK 簽名作廢。這種形式的 APK 驗證不僅速度要快得多,而且能夠發現更多種未經授權的修改。

簽名校驗

如何判斷是否有簽名校驗?不做任何修改,直接簽名安裝,應用閃退則說明大概率有簽名校驗。

一般來說,普通的簽名校驗會導致軟體的閃退,黑屏,卡啟動頁等。當然,以上都算是比較好的,有一些比較狠的作者,則會直接 rm -rf /,把基帶都格掉的一鍵變磚。

常見簽名校驗特徵:

  • kill/killProcess:可以殺死當前應用活動的進程,這一操作將會把所有該進程內的資源(包括線程全部清理掉).當然,由於ActivityManager時刻監聽著進程,一旦發現進程被非正常Kill,它將會試圖去重啟這個進程。這就是為什麼,有時候當我們試圖這樣去結束掉應用時,發現它又自動重新啟動的原因.
  • system.exit:殺死了整個進程,這時候活動所佔的資源也會被釋放。
  • finish:僅僅針對Activity,當調用finish()時,只是將活動推向後台,並沒有立即釋放內存,活動的資源並沒有被清理

三角校驗:so 檢測 dex,動態載入的 dex(在軟體運行時會解壓釋放一段dex文件,檢測完後就刪除)檢測 so,dex 檢測動態載入的 dex。
隱式簽名校驗:有一些則比較隱晦,在發現apk被修改後,會偷偷修改apk的部分功能,例如在某些多開定位軟體中,會暗改ip的經緯網等,跟實際產生一定的偏差。

普通獲取簽名校驗代碼:

private boolean SignCheck() {
    String trueSignMD5 = "d0add9987c7c84aeb7198c3ff26ca152";
    String nowSignMD5 = "";
    try {
        // 得到簽名的MD5
        PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(),PackageManager.GET_SIGNATURES);
        Signature[] signs = packageInfo.signatures;
        String signBase64 = Base64Util.encodeToString(signs[0].toByteArray());
        nowSignMD5 = MD5Utils.MD5(signBase64);
    } catch (PackageManager.NameNotFoundException e) {
        e.printStackTrace();
    }
    return trueSignMD5.equals(nowSignMD5);
}

簽名校驗對抗

  1. 核心破解插件,不簽名安裝應用(對於部分對比簽名文件的應用,不重新簽名就可以過掉簽名校驗)
  2. 一鍵過簽名工具,例如MT、NP、ARMPro、CNFIX、Modex的去除簽名校驗功能
  3. 具體分析簽名校驗邏輯(手撕簽名校驗)
  4. PM 代理(過時)
  5. IO重定向--VA&SVC:ptrace+seccomp
  6. 去作者家嚴刑拷打拿到 .jks 文件和密碼

手撕簽名校驗

定位

閃退攔截

演算法助手開啟」攔截應用退出「、」防止應用閃退「,打開簽名修改過的 app,可以在日誌里找到攔截日誌,通過調用堆棧定位到對應的簽名校驗代碼。

讀取應用簽名監聽

演算法助手開啟」讀取應用簽名監聽「,同理可以定位到簽名校驗代碼。

去除

把比對的簽名數據替換成修改後的簽名,或者修改判斷語句。

PM 代理

思路源自:Android中Hook 應用簽名方法

PMS

PackageManagerService(簡稱PMS),是 Android 系統核心服務之一,處理包管理相關的工作,常見的比如安裝、卸載應用等。

實現方法以及原理解析

HOOK PMS代碼:

package com.zj.hookpms;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

import android.content.Context;
import android.content.pm.PackageManager;
import android.util.Log;

public class ServiceManagerWraper {

    public final static String ZJ = "ZJ595";

    public static void hookPMS(Context context, String signed, String appPkgName, int hashCode) {
        try {
            // 獲取全局的ActivityThread對象
            Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
            Method currentActivityThreadMethod =
                    activityThreadClass.getDeclaredMethod("currentActivityThread");
            Object currentActivityThread = currentActivityThreadMethod.invoke(null);
            // 獲取ActivityThread裡面原始的sPackageManager
            Field sPackageManagerField = activityThreadClass.getDeclaredField("sPackageManager");
            sPackageManagerField.setAccessible(true);
            Object sPackageManager = sPackageManagerField.get(currentActivityThread);
            // 準備好代理對象, 用來替換原始的對象
            Class<?> iPackageManagerInterface = Class.forName("android.content.pm.IPackageManager");
            Object proxy = Proxy.newProxyInstance(
                    iPackageManagerInterface.getClassLoader(),
                    new Class<?>[]{iPackageManagerInterface},
                    new PmsHookBinderInvocationHandler(sPackageManager, signed, appPkgName, 0));
            // 1. 替換掉ActivityThread裡面的 sPackageManager 欄位
            sPackageManagerField.set(currentActivityThread, proxy);
            // 2. 替換 ApplicationPackageManager裡面的 mPM對象
            PackageManager pm = context.getPackageManager();
            Field mPmField = pm.getClass().getDeclaredField("mPM");
            mPmField.setAccessible(true);
            mPmField.set(pm, proxy);
        } catch (Exception e) {
            Log.d(ZJ, "hook pms error:" + Log.getStackTraceString(e));
        }
    }

    public static void hookPMS(Context context) {
        String Sign = "原包的簽名信息";
        hookPMS(context, Sign, "com.zj.hookpms", 0);
    }
}

用法

將編譯好 PM 代理的 dex 文件添加到 apk 中,然後將對應的簽名取出來調用 hookPMS 方法替換掉原有的簽名。

IO 重定向

IO 重定向,可以實現:

  1. 讓文件只讀,不可寫
  2. 禁止訪問文件
    • 過 Root 檢測,Xposed 檢測
  3. 路徑替換
    • 過簽名檢測(讀取原包)
    • 風控對抗(例:一個文件記錄 App 啟動的次數)

具體實現:

代碼及原理:

using namespace std;  
string packname;  
string origpath;  
string fakepath;  

int (*orig_open)(const char *pathname, int flags, ...);  
int (*orig_openat)(int,const char *pathname, int flags, ...);  
FILE *(*orig_fopen)(const char *filename, const char *mode);  
static long (*orig_syscall)(long number, ...);  
int (*orig__NR_openat)(int,const char *pathname, int flags, ...);  

void* (*orig_dlopen_CI)(const char *filename, int flag);  
void* (*orig_dlopen_CIV)(const char *filename, int flag, const void *extinfo);  
void* (*orig_dlopen_CIVV)(const char *name, int flags, const void *extinfo, void *caller_addr);  

static inline bool needs_mode(int flags) {  
    return ((flags & O_CREAT) == O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE);  
}  
bool startsWith(string str, string sub){  
    return str.find(sub)==0;  
}  

bool endsWith(string s,string sub){  
    return s.rfind(sub)==(s.length()-sub.length());  
}  
bool isOrigAPK(string  path){  

    if(path==origpath){  
        return true;  
    }  
    return false;  
}  
//該函數的功能是在打開一個文件時進行攔截,並在滿足特定條件時將文件路徑替換為另一個路徑  

//fake_open 函數有三個參數:  
//pathname:一個字元串,表示要打開的文件的路徑。  
//flags:一個整數,表示打開文件的方式,例如只讀、只寫、讀寫等。  
//mode(可選參數):一個整數,表示打開文件時應用的許可權模式。  
int fake_open(const char *pathname, int flags, ...) {  
    mode_t mode = 0;  
    if (needs_mode(flags)) {  
        va_list args;  
        va_start(args, flags);  
        mode = static_cast<mode_t>(va_arg(args, int));  
        va_end(args);  
    }  
    //LOGI("open,  path: %s, flags: %d, mode: %d",pathname, flags ,mode);  
    string cpp_path= pathname;  
    if(isOrigAPK(cpp_path)){  
        LOGI("libc_open, redirect: %s, --->: %s",pathname, fakepath.data());  
        return orig_open("/data/user/0/com.zj.wuaipojie/files/base.apk", flags, mode);  
    }  
    return  orig_open(pathname, flags, mode);  

}  

//該函數的功能是在打開一個文件時進行攔截,並在滿足特定條件時將文件路徑替換為另一個路徑  

//fake_openat 函數有四個參數:  
//fd:一個整數,表示要打開的文件的文件描述符。  
//pathname:一個字元串,表示要打開的文件的路徑。  
//flags:一個整數,表示打開文件的方式,例如只讀、只寫、讀寫等。  
//mode(可選參數):一個整數,表示打開文件時應用的許可權模式。  
//openat 函數的作用類似於 open 函數,但是它使用文件描述符來指定文件路徑,而不是使用文件路徑本身。這樣,就可以在打開文件時使用相對路徑,而不必提供完整的文件路徑。  
//例如,如果要打開相對於當前目錄的文件,可以使用 openat 函數,而不是 open 函數,因為 open 函數只能使用絕對路徑。  
//  
int fake_openat(int fd, const char *pathname, int flags, ...) {  
    mode_t mode = 0;  
    if (needs_mode(flags)) {  
        va_list args;  
        va_start(args, flags);  
        mode = static_cast<mode_t>(va_arg(args, int));  
        va_end(args);  
    }  
    LOGI("openat, fd: %d, path: %s, flags: %d, mode: %d",fd ,pathname, flags ,mode);  
    string cpp_path= pathname;  
    if(isOrigAPK(cpp_path)){  
        LOGI("libc_openat, redirect: %s, --->: %s",pathname, fakepath.data());  
        return  orig_openat(fd,fakepath.data(), flags, mode);  
    }  
    return orig_openat(fd,pathname, flags, mode);  

}  
FILE *fake_fopen(const char *filename, const char *mode) {  

    string cpp_path= filename;  
    if(isOrigAPK(cpp_path)){  
        return  orig_fopen(fakepath.data(), mode);  
    }  
    return orig_fopen(filename, mode);  
}  
//該函數的功能是在執行系統調用時進行攔截,並在滿足特定條件時修改系統調用的參數。  
//syscall 函數是一個系統調用,是程序訪問內核功能的方法之一。使用 syscall 函數可以調用大量的系統調用,它們用於實現操作系統的各種功能,例如打開文件、創建進程、分配內存等。  
//  
static long fake_syscall(long number, ...) {  
    void *arg[7];  
    va_list list;  

    va_start(list, number);  
    for (int i = 0; i < 7; ++i) {  
        arg[i] = va_arg(list, void *);  
    }  
    va_end(list);  
    if (number == __NR_openat){  
        const char *cpp_path = static_cast<const char *>(arg[1]);  
        LOGI("syscall __NR_openat, fd: %d, path: %s, flags: %d, mode: %d",arg[0] ,arg[1], arg[2], arg[3]);  
        if (isOrigAPK(cpp_path)){  
            LOGI("syscall __NR_openat, redirect: %s, --->: %s",arg[1], fakepath.data());  
            return orig_syscall(number,arg[0], fakepath.data() ,arg[2],arg[3]);  
        }  
    }  
    return orig_syscall(number, arg[0], arg[1], arg[2], arg[3], arg[4], arg[5], arg[6]);  

}  

//函數的功能是獲取當前應用的包名、APK 文件路徑以及庫文件路徑,並將這些信息保存在全局變數中  
//函數調用 GetObjectClass 和 GetMethodID 函數來獲取 context 對象的類型以及 getPackageName 方法的 ID。然後,函數調用 CallObjectMethod 函數來調用 getPackageName 方法,獲取當前應用的包名。最後,函數使用 GetStringUTFChars 函數將包名轉換為 C 字元串,並將包名保存在 packname 全局變數中  
//接著,函數使用 fakepath 全局變數保存了 /data/user/0/<packname>/files/base.apk 這樣的路徑,其中 <packname> 是當前應用的包名。  
//然後,函數再次調用 GetObjectClass 和 GetMethodID 函數來獲取 context 對象的類型以及 getApplicationInfo 方法的 ID。然後,函數調用 CallObjectMethod 函數來調用 getApplicationInfo 方法,獲取當前應用的 ApplicationInfo 對象。  
//它先調用 GetObjectClass 函數獲取 ApplicationInfo 對象的類型,然後調用 GetFieldID 函數獲取 sourceDir 欄位的 ID。接著,函數使用 GetObjectField 函數獲取 sourceDir 欄位的值,並使用 GetStringUTFChars 函數將其轉換為 C 字元串。最後,函數將 C 字元串保存在 origpath 全局變數中,表示當前應用的 APK 文件路徑。  
//最後,函數使用 GetFieldID 和 GetObjectField 函數獲取 nativeLibraryDir 欄位的值,並使用 GetStringUTFChars 函數將其轉換為 C 字元串。函數最後調用 LOGI 函數列印庫文件路徑,但是並沒有將其保存在全局變數中。  

extern "C" JNIEXPORT void JNICALL  
Java_com_zj_wuaipojie_util_SecurityUtil_hook(JNIEnv *env, jclass clazz, jobject context) {  
    jclass conext_class = env->GetObjectClass(context);  
    jmethodID methodId_pack = env->GetMethodID(conext_class, "getPackageName",  
                                               "()Ljava/lang/String;");  
    auto packname_js = reinterpret_cast<jstring>(env->CallObjectMethod(context, methodId_pack));  
    const char *pn = env->GetStringUTFChars(packname_js, 0);  
    packname = string(pn);  

    env->ReleaseStringUTFChars(packname_js, pn);  
    //LOGI("packname: %s", packname.data());  
    fakepath= "/data/user/0/"+ packname +"/files/base.apk";  

    jclass conext_class2 = env->GetObjectClass(context);  
    jmethodID methodId_pack2 = env->GetMethodID(conext_class2,"getApplicationInfo","()Landroid/content/pm/ApplicationInfo;");  
    jobject application_info = env->CallObjectMethod(context,methodId_pack2);  
    jclass pm_clazz = env->GetObjectClass(application_info);  

    jfieldID package_info_id = env->GetFieldID(pm_clazz,"sourceDir","Ljava/lang/String;");  
    auto sourceDir_js = reinterpret_cast<jstring>(env->GetObjectField(application_info,package_info_id));  
    const char *sourceDir = env->GetStringUTFChars(sourceDir_js, 0);  
    origpath = string(sourceDir);  
    LOGI("sourceDir: %s", sourceDir);  

    jfieldID package_info_id2 = env->GetFieldID(pm_clazz,"nativeLibraryDir","Ljava/lang/String;");  
    auto nativeLibraryDir_js = reinterpret_cast<jstring>(env->GetObjectField(application_info,package_info_id2));  
    const char *nativeLibraryDir = env->GetStringUTFChars(nativeLibraryDir_js, 0);  
    LOGI("nativeLibraryDir: %s", nativeLibraryDir);  
    //LOGI("%s", "Start Hook");  

    //啟動hook  
    void *handle = dlopen("libc.so",RTLD_NOW);  
    auto pagesize = sysconf(_SC_PAGE_SIZE);  
    auto addr = ((uintptr_t)dlsym(handle,"open") & (-pagesize));  
    auto addr2 = ((uintptr_t)dlsym(handle,"openat") & (-pagesize));  
    auto addr3 = ((uintptr_t)fopen) & (-pagesize);  
    auto addr4 = ((uintptr_t)syscall) & (-pagesize);  

    //解除部分機型open被保護  
    mprotect((void*)addr, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);  
    mprotect((void*)addr2, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);  
    mprotect((void*)addr3, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);  
    mprotect((void*)addr4, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);  

    DobbyHook((void *)dlsym(handle,"open"), (void *)fake_open, (void **)&orig_open);  
    DobbyHook((void *)dlsym(handle,"openat"), (void *)fake_openat, (void **)&orig_openat);  
    DobbyHook((void *)fopen, (void *)fake_fopen, (void**)&orig_fopen);  
    DobbyHook((void *)syscall, (void *)fake_syscall, (void **)&orig_syscall);  
}

只需要在需要重定向的地方調用:

    sget-object p10, Lcom/zj/wuaipojie/util/ContextUtils;->INSTANCE:Lcom/zj/wuaipojie/util/ContextUtils;  

    invoke-virtual {p10}, Lcom/zj/wuaipojie/util/ContextUtils;->getContext()Landroid/content/Context;  

    move-result-object p10  

    invoke-static {p10}, Lcom/zj/wuaipojie/util/SecurityUtil;->hook(Landroid/content/Context;)V

通過系統自帶的api去獲取簽名很容易被偽造,不妨試試通過SVC的方式去獲取(參考MT開源的方法)。

root 檢測

原理

fun isDeviceRooted(): Boolean {
    return checkRootMethod1() || checkRootMethod2() || checkRootMethod3()
}

fun checkRootMethod1(): Boolean {
    val buildTags = android.os.Build.TAGS
    return buildTags != null && buildTags.contains("test-keys")
}

fun checkRootMethod2(): Boolean {
    val paths = arrayOf("/system/app/Superuser.apk", "/sbin/su", "/system/bin/su", "/system/xbin/su", "/data/local/xbin/su", "/data/local/bin/su", "/system/sd/xbin/su",
            "/system/bin/failsafe/su", "/data/local/su", "/su/bin/su")
    for (path in paths) {
        if (File(path).exists()) return true
    }
    return false
}

fun checkRootMethod3(): Boolean {
    var process: Process? = null
    return try {
        process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))
        val bufferedReader = BufferedReader(InputStreamReader(process.inputStream))
        bufferedReader.readLine() != null
    } catch (t: Throwable) {
        false
    } finally {
        process?.destroy()
    }
}

定義了一個 isDeviceRooted() 函數,該函數調用了三個檢測 root 的方法:checkRootMethod1()checkRootMethod2()checkRootMethod3()

checkRootMethod1() 方法檢查設備的 build tags 是否包含 test-keys。這通常是用於測試的設備,因此如果檢測到這個標記,則可以認為設備已被 root。

checkRootMethod2() 方法檢查設備是否存在一些特定的文件,這些文件通常被用於執行 root 操作。如果檢測到這些文件,則可以認為設備已被 root。

checkRootMethod3() 方法使用 Runtime.exec() 方法來執行 which su 命令,然後檢查命令的輸出是否不為空。如果輸出不為空,則可以認為設備已被 root。

反制手段

  1. 演算法助手、對話框取消等插件一鍵 hook
  2. 分析具體的檢測代碼並去除
  3. 利用 IO 重定向使文件不可讀
  4. 修改 Andoird 源碼,去除常見指紋

模擬器檢測

fun isEmulator(): Boolean { 
        return Build.FINGERPRINT.startsWith("generic") || Build.FINGERPRINT.startsWith("unknown") || Build.MODEL.contains("google_sdk") Build.MODEL.contains("Emulator") || Build.MODEL.contains("Android SDK built for x86") || Build.MANUFACTURER.contains("Genymotion") || Build.HOST.startsWith("Build") || Build.PRODUCT == "google_sdk" 
        }

通過檢測系統的 Build 對象來判斷當前設備是否為模擬器。具體方法是檢測 Build.FINGERPRINT 屬性是否包含字元串 "generic"

具體分析見模擬器檢測對抗

反調試檢測

安卓系統自帶調試檢測函數

fun checkForDebugger() {  
    if (Debug.isDebuggerConnected()) {  
        // 如果調試器已連接,則終止應用程序  
        System.exit(0)  
    }  
}

debuggable 屬性

public boolean getAppCanDebug(Context context)//上下文對象為xxActivity.this
{
    boolean isDebug = context.getApplicationInfo() != null &&
            (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
    return isDebug;
}

ptrace檢測

int ptrace_protect()//ptrace附加自身線程 會導致此進程TracerPid 變為父進程的TracerPid 即zygote
{
    return ptrace(PTRACE_TRACEME,0,0,0);;//返回-1即為已經被調試
}

每個進程同時刻只能被1個調試進程ptrace ,主動ptrace本進程可以使得其他調試器無法調試

調試進程名檢測

int SearchObjProcess()
{
    FILE* pfile=NULL;
    char buf[0x1000]={0};

    pfile=popen("ps","r");
    if(NULL==pfile)
    {
        //LOGA("SearchObjProcess popen打開命令失敗!\n");
        return -1;
    }
    // 獲取結果
    //LOGA("popen方案:\n");
    while(fgets(buf,sizeof(buf),pfile))
    {

        char* strA=NULL;
        char* strB=NULL;
        char* strC=NULL;
        char* strD=NULL;
        strA=strstr(buf,"android_server");//通過查找匹配子串判斷
        strB=strstr(buf,"gdbserver");
        strC=strstr(buf,"gdb");
        strD=strstr(buf,"fuwu");
        if(strA || strB ||strC || strD)
        {
            return 1;
            // 執行到這裡,判定為調試狀態

        }
    }
    pclose(pfile);
    return 0;
}

[原創]對安卓反調試和校驗檢測的一些實踐與結論

frida 檢測

一些Frida檢測手段


本文鏈接:https://linuxstory.org/android-reverse-signature-verification-confrontation/
原文鏈接:https://www.52pojie.cn/thread-1731181-1-1.html

Linux Story 整理,對原文有刪節、補充;轉載請註明,否則將追究相關責任!

對這篇文章感覺如何?

太棒了
2
不錯
0
愛死了
0
不太好
0
感覺很糟
1

You may also like

Leave a reply

您的電子郵箱地址不會被公開。 必填項已用 * 標註

此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

More in:教程

教程

在 Ubuntu 像22.04 LTS Linux 安裝 JUnit 5

JUnit 不僅簡單而且是一種有效的方法來編寫和執行 Java 應用程序的單元測試,因此它是開源類別中使用最廣泛的測試框架。 JUnit的最新版本5發布時帶來了許多改進。 所以,如果你使用Ubuntu […]
教程

同時運行多個 Linux 命令

了解如何在 Linux 中同時執行多個命令可以顯著提高您的效率和生產力。本文將指導您通過各種方式在單行中運行多個 Linux 命令,甚至如何自動化重複的任務。 理解基礎知識 在深入了解高級技巧之前,您 […]