[譯]再談如何安全地在 Android 中儲存令牌

lovexiaov發表於2017-06-08

[譯]再談如何安全地在 Android 中儲存令牌

作為本文的序言,我想對讀者做一個簡短的宣告。下面的引言對本文的後續內容而言十分重要。

沒有絕對的安全。所謂的安全是指利用一系列措施的堆積和組合,來試圖延緩必然發生的事情。

大約 3 年前,我寫了一篇文章,給出了幾種方法來防止潛在攻擊者反編譯我們 Android 應用竊取字串令牌。為了便於回憶,也為了防止不可避免的網路癱瘓,我將會在此重新列出一些章節。

客戶端應用與服務端的互動是最常見的場景之一。資料交換時的敏感度差別很大,並且登入請求、使用者資料更改請求等之間交換的資料型別也變化多樣。

首先要提到並應用的技術是使用 SSL(安全套接層)連結客戶端與服務端。再看一下文章開頭的引言。儘管這樣做是一個良好的開端,但這並不能確保絕對的隱私和安全。

當你使用 SSL 連線時(也就是當你看到瀏覽器上有一個小鎖時),這意味著你與伺服器之間的連線被加密了。理論上講,沒有什麼能夠訪問到你請求裡的資訊(*)

(*)我說過絕對的安全不存在吧?SSL 連線仍然可以被攻破。本文不打算提供所有可能的攻擊手段列表,只想讓你瞭解幾種攻擊的可能性。比如,可以偽造 SSL 證照,或者進行中間人攻擊。

我們繼續。假設客戶端正在通過加密的 SSL 通道與後臺連結,它們在愉快的交換有用的資料,執行業務邏輯。但是我們還想提供一個額外的安全層。

接下來要採取的措施是在通訊中使用授權令牌或 API 金鑰。當後臺收到一個請求時,我們如何判斷該請求是來自認證的客戶端而不是任意一個想要獲取我們 API 資料的傢伙?後臺會檢查該客戶端是否提供了一個有效的 API 金鑰。如果金鑰有效,則執行請求操作,否則拒絕該請求並根據業務需求採取一些措施(當出現此情況時,我一般會紀錄他們的 IP 地址和客戶端 ID,看一下他們的訪問頻率。如果頻率高於我的忍受範圍,我會考慮禁止並觀察一下這個無禮的傢伙想要得到什麼)。

讓我們從頭開始構建我們的城堡吧。在我們的應用中,新增一個叫做 API_KEY 的變數,該變數會自動注入到每次的請求(如果是 Android 應用,可能會是你的 Retrofit 客戶端)中。

private final static String API_KEY = “67a5af7f89ah3katf7m20fdj202”複製程式碼

很好,這樣可以幫助我們鑑定客戶端。但問題在於它本身並沒有提供一個十分有效的安全保證。

如果你使用 apktool 反編譯該應用,然後搜尋該字串,你會在其中一個 .smali 檔案中發現:

const-string v1, “67a5af7f89ah3katf7m20fdj202”複製程式碼

是的,我知道。這並不能保證是一個有效的令牌,所以我們仍然需要通過一個精確的驗證來決定如何找到那個字串,和它是否可以用來通過驗證。但是你知道我要表達什麼:這通常只是時間和資源的問題。

Proguard 是否會能我們保證該字串的安全呢?並不能。Proguard 在常見問題中提到了字串的加密是完全不可能的。

那將字串儲存到 Android 提供的其他儲存機制中呢,比如說 SharedPreferences?這並不是一個好方法。在模擬器或者 root 過的裝置中可以輕易的訪問到 SharedPreferences。幾年前,一個叫 Srinivas 的夥計向我們證明了如何更改一個視訊遊戲中的得分。跑題了!

原生開發工具包 (NDK)

我將會更新我提出的初始模型,不斷迭代它,以提供更安全的替代方案。我們假設有兩個函式分別負責加密和解密資料:

 private static byte[] encrypt(byte[] raw, byte[] clear) throws Exception {
        SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES");
        Cipher cipher = Cipher.getInstance("AES");
        cipher.init(Cipher.ENCRYPT_MODE, skeySpec);
        byte[] encrypted = cipher.doFinal(clear);
        return encrypted;
    }

    private static byte[] decrypt(byte[] raw, byte[] encrypted) throws Exception {
        SecretKeySpec skeySpec = new SecretKeySpec(raw, "AES");
        Cipher cipher = Cipher.getInstance("AES");
        cipher.init(Cipher.DECRYPT_MODE, skeySpec);
        byte[] decrypted = cipher.doFinal(encrypted);
        return decrypted;
    }複製程式碼

程式碼沒啥好說的。這兩個函式會使用一個金鑰值和一個被用來編/解碼的字串作為入參。它們會返回相應的加密或解密過的字串。我們會用如下方式呼叫它們:

ByteArrayOutputStream baos = new ByteArrayOutputStream();
bm.compress(Bitmap.CompressFormat.PNG, 100, baos);
byte[] b = baos.toByteArray();

byte[] keyStart = "encryption key".getBytes();
KeyGenerator kgen = KeyGenerator.getInstance("AES");
SecureRandom sr = SecureRandom.getInstance("SHA1PRNG");
sr.setSeed(keyStart);
kgen.init(128, sr);
SecretKey skey = kgen.generateKey();
byte[] key = skey.getEncoded();

// encrypt
byte[] encryptedData = encrypt(key,b);
// decrypt
byte[] decryptedData = decrypt(key,encryptedData);複製程式碼

猜到為什麼要這麼做了嗎?是的,我們可以根據需求來加/解密令牌。這就為我們提供了一個額外的安全層:當程式碼混淆後,尋找令牌不再像執行字串搜尋和檢查字串周圍的環境那樣簡單了。但是,你能指出還有一個需要解決的問題嗎?

找到了嗎?

如果還沒找到就多花點時間。

是的。我們仍然有一個加密金鑰以字串的形式儲存。雖然這種隱晦的做法增加了更多的安全層,但不管這個令牌是用於加密或它本身就是一個令牌,我們仍然有一個以明文形式存在的令牌。

現在,我們將使用 NDK 來繼續迭代我們的安全機制。

NDK 允許我們在 Android 程式碼中訪問 C++ 程式碼庫。首先我們來想一下要做什麼。我們可以在一個 C++ 函式中存放 API 金鑰或者敏感資料。該函式可以在之後的程式碼中呼叫,避免了在 Java 檔案中儲存字串。這就提供了一個自動的保護機制來防止反編譯技術。

C++ 函式如下:

Java_com_example_exampleApp_ExampleClass_getSecretKey( JNIEnv* env,
                                                  jobject thiz )
{
    return (*env)->NewStringUTF(env, "mySecretKey".");
}複製程式碼

在 Java 程式碼中呼叫它也很簡單:

static {
        System.loadLibrary("library-name");
    }

public native String getSecretKey();複製程式碼

在加/解密函式中會這樣呼叫:

byte[] keyStart = getSecretKey().getBytes();複製程式碼

此時我們生成 APK,混淆它,然後反編譯並嘗試在原生函式 getSecretKey() 中查詢該字串,無法找到!勝利了嗎?

並沒有!NDK 程式碼其實也可以被反彙編和檢查。只是難度較高,需要更高階的工具和技術。雖然這樣可以擺脫掉 95% 的指令碼小子,但一個有充足資源和動機的團隊讓然可以拿到令牌。還記得這句話嗎?

沒有絕對的安全。所謂的安全是指利用一系列措施的堆積和組合,來試圖延緩必然發生的事情。

[譯]再談如何安全地在 Android 中儲存令牌

你仍然可以在反彙編程式碼中找到該字串字面值。Hex Rays 在反編譯原生檔案方面就做的很好。我很確信有一大堆的工具可以解構 Android 生成的任意原生程式碼(我跟 Hex Rays 並沒有關係,也沒有從他們那裡拿到任何形式的資金酬勞)。

那麼,我們要使用哪種方案來避免後臺與客戶端的通訊被標記呢?

在裝置上實時生成金鑰。

你的裝置不需要儲存任何形式的金鑰並處理各種保護字串字面值的麻煩!這是在服務中用到的非常古老的技術,比如遠端金鑰驗證。

  1. 客戶端知道有個函式會返回一個金鑰。
  2. 後臺知道在客戶端中實現的那個函式。
  3. 客戶端通過該函式生成一個金鑰,併傳送到伺服器上。
  4. 伺服器驗證金鑰,並根據請求執行相應的操作。

抓到重點了嗎?為什麼不使用返回三個隨機素數( 1~100 之間)之和的函式來代替返回一個字串(很容易被識別)的原生函式呢?或者拿到當天的 UNIX 時間,然後給每一位數字加 1?通過裝置的一些上下文相關資訊(如正在使用的記憶體量)來提供一個更高程度的熵值?

上面這段包含了一些想法,希望讀者們已經得到重點了。

總結

  1. 絕對的安全是不存在的。
  2. 多種保護手段的結合是達到高安全度的關鍵。
  3. 不要在程式碼中儲存字串明文。
  4. 使用 NDK 來建立自生成的金鑰。

還記得開頭的那段話吧?

沒有絕對的安全。所謂的安全是指利用一系列措施的堆積和組合,來試圖延緩必然發生的事情。

我想再強調一次,你的目標是儘可能的保護你的程式碼,同時不要忘記 100% 的安全是不可能的。但是,如果你能保證解密你程式碼中任意的敏感資訊都需要耗費大量的資源,你就能安心睡覺啦。

一個小小的免責宣告

我知道,讀到此處,縱觀整文,你會納悶“這傢伙怎麼講了所有麻煩的方法而沒有提到 Dexguard 呢?”。是的 Dexguard 可以混淆字串,他們在這方面做的很好。然而 Dexguard 的售價讓人望而卻步。我在之前的公司的關鍵安全系統中使用過 Dexguard,但這也許並不是一個適合所有人的選擇。再說了,像生活一樣,在軟體開發中選擇越多世界越豐富多彩。

愉快的編碼吧!

我會在 Twitter 上寫一些關於軟體工程和生活點滴的思考。如果你喜歡此文,或者它能幫到你,請隨意分享,點贊或者留言。這是業餘作者寫作的動力。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章