Tài liệu
/
Công thức
/

Ẩn tên hàm khỏi phân tích của LLM

Ẩn tên hàm khỏi phân tích của LLM

Vấn đề

Bạn đã bật vmObfuscation: true, chạy nó trên một tệp chứa một hàm như validateLicense, và nhận thấy rằng đầu ra đã làm rối vẫn chứa đúng chữ validateLicense - phần thân đã biến mất, được thay bằng bytecode, nhưng bản thân cái tên thì vẫn nằm phơi bày ở đó.

// Input
function validateLicense(token) {
    const decoded = decodeBase64(token);
    return verifySignature(decoded);
}

// Output - name is preserved, body is bytecode
function validateLicense(b) {
    return vmq_1bac70(0x5, [], undefined, undefined, undefined, this);
}

Nhân điều đó lên trên một cơ sở mã thực tế và bạn sẽ có một danh sách tên hàm như validateLicense, decryptPayload, processPayment, checkSubscription. Một LLM không cần bẻ khóa bytecode để hiểu chương trình làm gì - riêng các tên đã đủ để nó đưa ra một bản tóm tắt tự tin, chính xác về hành vi của mô-đun. Bytecode thì khó đọc; còn mục lục thì không.

Vì sao làm rối VM giữ lại các tên này

Với vmTargetFunctionsMode: 'root' (mặc định), trình làm rối biến đổi thân của mỗi hàm ở cấp gốc thành bytecode VM nhưng cố ý giữ nguyên tên. Về mặt ngữ nghĩa, một khai báo hàm ở cấp gốc là một ràng buộc trên phạm vi bao quanh - với một script thì nghĩa là đối tượng toàn cục, với một module thì nghĩa là không gian tên của module. Trình làm rối không thể đổi tên nó một cách an toàn vì nó không có cách nào biết ai khác tham chiếu đến nó: một bundle khác, một thẻ <script> nội tuyến, một thuộc tính HTML onclick="validateLicense(...)", một tra cứu động window['validateLicense'], v.v.

Vậy nên sự đánh đổi mà mặc định lựa chọn là: bảo vệ phần cài đặt, giữ nguyên bề mặt công khai. Điều đó giữ cho việc tích hợp không bị hỏng, nhưng cũng có nghĩa là một LLM được tặng miễn phí một bảng chỉ mục của mọi điểm vào.

Vì sao điều này quan trọng đối với việc dịch ngược có hỗ trợ của LLM

Một kẻ tấn công là con người đứng trước vài trăm dòng điều phối bytecode thường sẽ bỏ cuộc. Một LLM được đưa cùng tệp đó sẽ chẳng buồn tấn công bytecode - nó sẽ đọc các tên, đối chiếu với vài chuỗi ký tự ít ỏi mà nó có thể thấy, và phát ra một thứ gì đó kiểu:

Bản tóm tắt đó đủ để một kẻ tấn công lên kế hoạch vượt qua có chủ đích mà không bao giờ phải chạm vào VM. Các tên chính là chỗ rò rỉ.

Cách khắc phục: bọc mã của bạn trong một IIFE

Cách đơn giản và bền vững nhất để loại bỏ chỗ rò rỉ này là đẩy các hàm nhạy cảm của bạn xuống sâu hơn một cấp trong cây phạm vi. Các hàm được khai báo bên trong một hàm khác thì không ở cấp gốc, nên trình làm rối được tự do đổi tên chúng và gộp các khai báo của chúng vào bytecode như bất kỳ câu lệnh nào khác.

Một IIFE (Immediately-Invoked Function Expression - biểu thức hàm được gọi ngay) là cách nhẹ nhất để làm điều đó - nó thêm vào một hàm bao duy nhất chạy một lần và không phơi bày bất cứ thứ gì theo tên.

Trước - các tên bị phơi bày

function validateLicense(token) {
    const decoded = decodeBase64(token);
    return verifySignature(decoded);
}

function checkExpiry(license) {
    return Date.now() < license.expiresAt;
}

document.querySelector('#activate').addEventListener('click', () => {
    const token = document.querySelector('#token').value;
    if (validateLicense(token)) {
        unlockUI();
    }
});

Sau khi làm rối VM, cả validateLicense lẫn checkExpiry đều còn sống theo tên trong đầu ra.

Sau - các tên được giấu sau một IIFE

(function () {
    function validateLicense(token) {
        const decoded = decodeBase64(token);
        return verifySignature(decoded);
    }

    function checkExpiry(license) {
        return Date.now() < license.expiresAt;
    }

    document.querySelector('#activate').addEventListener('click', () => {
        const token = document.querySelector('#token').value;
        if (validateLicense(token)) {
            unlockUI();
        }
    });
})();

Giờ đây cả hai khai báo hàm đều nằm bên trong thân của IIFE. Bản thân IIFE là cấu trúc cấp gốc duy nhất, và một IIFE ẩn danh thì không có tên nào để rò rỉ. Sau khi làm rối VM, toàn bộ thân - bao gồm mọi khai báo bên trong nó - được chuyển thành bytecode và mã hóa; không gì bên trong IIFE còn sống dưới dạng văn bản đọc được.

Nếu một hàm thực sự cần phải là biến toàn cục thì sao?

Đôi khi một hàm thực sự là một điểm vào công khai - một trình xử lý sự kiện nội tuyến, một callback JSONP, một hook của SDK bên thứ ba. Bạn có hai lựa chọn:

  • Phơi bày một bàn đạp (trampoline) mỏng, giữ logic bên trong IIFE. Khai báo một hàm bao toàn cục nhỏ mà nhiệm vụ duy nhất là gọi vào phần cài đặt nằm trong phạm vi IIFE. Tên của bàn đạp vẫn rò rỉ, nhưng nó không mang thông tin ngữ nghĩa nào - hãy đặt tên nó là __entry1 hoặc tương tự - và toàn bộ logic có ý nghĩa vẫn được giấu kín.

    var __entry1;
    (function () {
        function validateLicense(token) { /* … */ }
        __entry1 = validateLicense;
    })();
    // Outside code calls __entry1(token) instead of validateLicense(token).
  • Viết lại vị trí gọi mà bạn không kiểm soát. Nếu biến toàn cục chỉ tồn tại vì một onclick="validateLicense(...)" nội tuyến cần đến nó, hãy thay trình xử lý nội tuyến bằng addEventListener từ bên trong IIFE. HTML thôi không còn gọi tên hàm nữa, hàm thôi không còn cần là biến toàn cục nữa, và chỗ rò rỉ biến mất hoàn toàn.

Các bộ khởi tạo biến ở cấp cao nhất: vmWrapTopLevelInitializers

Khai báo hàm không phải là thứ duy nhất nằm ở gốc của một tệp. Các bộ khởi tạo biến ở cấp cao nhất - các hằng chuỗi, đối tượng cấu hình, bảng tra cứu - cũng đọc được y như vậy trong đầu ra theo mặc định. Một dòng như const API_BASE = '/api/v2/license' nói cho một LLM biết nhiều ngang với function validateLicense.

Tùy chọn vmWrapTopLevelInitializers (boolean, mặc định false) bọc các bộ khởi tạo cấp cao nhất đủ điều kiện trong một IIFE để bản thân giá trị được tính bằng bytecode VM khi chạy thay vì nằm trong mã nguồn dưới dạng một hằng.

Không dùng tùy chọn

// Input
const MY_STRING = 'my-string';

// Output - string is visible
const MY_STRING = 'my-string';

Với vmWrapTopLevelInitializers: true

// Input
const MY_STRING = 'my-string';

// Output - initializer is now a VM call, the string lives inside bytecode
const MY_STRING = (() => {
    return vmq_1bac70(0x5, [], undefined, undefined, undefined, this);
})();

Tên ràng buộc (MY_STRING) vẫn ở cấp gốc vì cùng lý do như tên hàm - thứ gì đó bên ngoài tệp có thể tham chiếu đến nó - nhưng giá trị mà nó nắm giữ giờ đây được VM tạo ra và không còn xuất hiện dưới dạng văn bản đọc được.

Khi từng đó vẫn chưa đủ

  • Các tên được import từ những module khác. Nếu bạn gộp nhiều tệp và một module export validateLicense để một module khác import, bundler sẽ giữ cho tên đó hiện diện trong đầu ra đã gộp theo cùng cách mà các hàm cấp gốc hiện diện. Hãy bọc chính bundle trong một IIFE (hầu hết các bundler đều làm được), hoặc chuyển phần export vào bên trong một IIFE và phơi bày lại nó qua một bàn đạp vô nghĩa.