0

Bảo mật client cho Banking App của bạn — Phần 1: Hybrid Encryption

Lời mở đầu

Bạn đã bao giờ tự hỏi tại sao khi dùng Burp Suite hay Charles Proxy để intercept traffic của app ngân hàng, dù đã cài cert, bạn vẫn chỉ thấy một đống ký tự vô nghĩa thay vì JSON đẹp như các app thông thường?

Đó không phải HTTPS làm — mà là một lớp mã hóa nằm bên trên HTTPS, ở tầng application. Bài này sẽ giải thích cơ chế đó hoạt động như thế nào, tại sao người ta thiết kế vậy, và nếu bạn là developer thì nên implement ra sao.


1. Tại sao HTTPS chưa đủ?

HTTPS bảo vệ dữ liệu trong quá trình truyền (transport layer). Nhưng nó không bảo vệ bạn khỏi:

  • Nếu attacker có khả năng MITM hoặc thiết bị người dùng bị kiểm soát thì việc chỉ dựa vào HTTPS có thể chưa đủ.
  • Compromised Device: Trên thiết bị đã root/jailbreak, user có thể cài CA cert tùy ý → intercept được toàn bộ HTTPS traffic.
  • Reverse Engineering: Developer dùng proxy tool (Burp, mitmproxy) để đọc request/response trong quá trình debug hoặc nghiên cứu.

Giải pháp: Thêm một lớp mã hóa ở tầng application — nghĩa là payload đã được mã hóa trước khi giao cho HTTPS gửi đi. Dù có intercept được TLS, bạn vẫn chỉ thấy ciphertext.


2. Bài toán: Chọn thuật toán mã hóa nào?

Chỉ dùng RSA?

Bài toán: RSA là thuật toán asymmetric — server giữ private key, client dùng public key mã hóa. Nghe có vẻ hoàn hảo. Nhưng có 2 vấn đề lớn:

  • RSA giới hạn kích thước dữ liệu có thể mã hóa. Với key 2048-bit, bạn chỉ mã hóa được tối đa ~245 bytes. Một JSON payload login đơn giản đã vượt giới hạn này.
  • RSA chậm hơn AES hàng trăm lần — không phù hợp mã hóa payload lớn theo từng request.

Chỉ dùng AES?

Bài toán: AES là thuật toán symmetric — cùng một key để mã hóa và giải mã. Nhanh, không giới hạn kích thước. Nhưng:

  • Key phải được chia sẻ an toàn giữa client và server. Nếu key bị lộ → toàn bộ traffic bị decrypt.
  • Nếu dùng cùng một key cho mọi request → attacker capture được key một lần là xong.

3. Giải pháp: Hybrid Encryption

Kết hợp ưu điểm của cả hai:

AES mã hóa data (nhanh, không giới hạn size) → RSA mã hóa AES key (an toàn, chỉ cần mã hóa ~32 bytes)

Flow tổng quan:

[Client]
  │
  ├─ 1. Sinh random AES key (32 bytes) + IV (16 bytes)
  │
  ├─ 2. Mã hóa payload bằng AES-CTR(key, IV)
  │       → ciphertext
  │
  ├─ 3. Ghép IV + ciphertext
  │       → combined bytes
  │
  ├─ 4. Mã hóa AES key bằng RSA Public Key của server
  │       → encrypted_key
  │
  └─ 5. Gửi lên server: { d: base64(combined), k: base64(encrypted_key) }

[Server]
  │
  ├─ 1. Dùng RSA Private Key giải mã k → AES key
  │
  ├─ 2. Tách IV từ 16 bytes đầu của d
  │
  └─ 3. Giải mã phần còn lại bằng AES-CTR(key, IV) → plaintext JSON

4. Chi tiết từng bước

Bước 1 — Sinh AES Key và IV ngẫu nhiên

AES Key: 32 bytes random  (256-bit AES)
IV:      16 bytes random  (Initialization Vector)

Tại sao random mỗi request?

Đây là điểm quan trọng nhất. Nếu dùng key cố định:

  • Attacker capture được 1 request → có key → decrypt toàn bộ traffic cũ và mới.
  • Dễ bị replay attack: copy nguyên request cũ gửi lại.

Random AES key mỗi request giúp giảm ảnh hưởng nếu một khóa bị lộ và hạn chế việc tái sử dụng khóa.


Bước 2 — Mã hóa payload bằng AES-CTR

Tại sao AES-CTR thay vì AES-CBC phổ biến hơn?

AES-CBC AES-CTR
Padding Cần padding (PKCS7) Không cần padding
Parallel Không thể song song hóa Song song hóa được
Error propagation Lỗi 1 block → lỗi block tiếp Lỗi 1 block, block khác không ảnh hưởng
Stream-like Không Có — phù hợp dữ liệu streaming

AES-CTR biến AES block cipher thành stream cipher — linh hoạt hơn cho payload có kích thước không cố định.


Bước 3 — Mã hóa AES Key bằng RSA

Server giữ private key tương ứng. Chỉ server mới giải mã được AES key này.

Kết quả cuối cùng gửi lên server:

{
  "d": "base64(IV + ciphertext)",
  "k": "base64(RSA_encrypted_AES_key)"
}

5. Server giải mã như thế nào?

1. Lấy k → RSA_DECRYPT(privateKey, k) → aesKey
2. Lấy d → base64_decode(d) → combined bytes
3. Tách: IV = combined[0:16], ciphertext = combined[16:]
4. Giải mã: plaintext = AES-CTR(aesKey, IV, ciphertext)
5. Parse JSON → xử lý request bình thường

6. Demo thực tế

Để hiểu rõ hơn, mình dựng một demo hoàn chỉnh gồm server + client chạy local, không liên quan bất kỳ hệ thống thật nào.

Cài đặt

npm install express node-forge

Server (server.js)

Server tự generate RSA keypair khi khởi động, expose public key, nhận và giải mã request.

const express = require('express');
const forge = require('node-forge');
const app = express();
app.use(express.json());

// Server tự generate RSA keypair — demo only
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
const privateKey = keypair.privateKey;
const publicKeyPem = forge.pki.publicKeyToPem(keypair.publicKey);
const publicKeyBase64 = forge.util.encode64(publicKeyPem);

// Expose public key để client lấy
app.get('/public-key', (req, res) => {
    res.json({ publicKey: publicKeyBase64 });
});

// Nhận và giải mã request
app.post('/api/login', (req, res) => {
    const { d, k } = req.body;

    try {
        // 1. Giải mã AES key bằng RSA private key
        const encryptedKeyBytes = forge.util.decode64(k);
        const aesKeyBase64 = privateKey.decrypt(encryptedKeyBytes);
        const aesKey = forge.util.decode64(aesKeyBase64);

        // 2. Decode combined bytes (IV + ciphertext)
        const combined = Buffer.from(d, 'base64');
        const iv = combined.slice(0, 16).toString('binary');
        const ciphertext = combined.slice(16).toString('binary');

        // 3. Giải mã bằng AES-CTR
        const decipher = forge.cipher.createDecipher('AES-CTR', aesKey);
        decipher.start({ iv });
        decipher.update(forge.util.createBuffer(ciphertext));
        decipher.finish();

        const plaintext = decipher.output.toString('utf8');
        const payload = JSON.parse(plaintext);

        console.log('Decrypted payload:', payload);
        res.json({ success: true, received: payload });

    } catch (err) {
        res.status(400).json({ success: false, error: err.message });
    }
});

app.listen(3000, () => console.log('Server running at http://localhost:3000'));

Client (client.js)

const forge = require('node-forge');

// Payload demo — hoàn toàn giả
const payload = {
    DT: 'WINDOWS',
    E: '',
    OV: '138.0.0.0',
    PM: 'Chrome',
    appVersion: '2.9.9.61',
    captchaToken: "1d01e642-cd0b-3a8b-6443-aac7771363d9",
    captchaValue: "6rayx",
    clientId: '',
    mid: 1,
    pin: 'abc@1234567',
    user: 'sodienthoai',
};

async function encryptPayload(data, publicKeyBase64) {
    // 1. Sinh random AES key + IV — mỗi request khác nhau
    const aesKey = forge.random.getBytesSync(32);
    const iv = forge.random.getBytesSync(16);

    // 2. Mã hóa payload bằng AES-CTR
    const cipher = forge.cipher.createCipher('AES-CTR', aesKey);
    cipher.start({ iv });
    cipher.update(forge.util.createBuffer(forge.util.encodeUtf8(JSON.stringify(data))));
    cipher.finish();

    // 3. Ghép IV + ciphertext
    const combined = Buffer.concat([
        Buffer.from(iv, 'binary'),
        Buffer.from(cipher.output.data, 'binary'),
    ]);

    // 4. Mã hóa AES key bằng RSA public key
    const pem = forge.util.decode64(publicKeyBase64);
    const rsaPublicKey = forge.pki.publicKeyFromPem(pem);
    const encryptedKeyBytes = rsaPublicKey.encrypt(forge.util.encode64(aesKey));

    return {
        d: combined.toString('base64'),
        k: forge.util.encode64(encryptedKeyBytes),
    };
}

async function main() {
    // Bước 1: Lấy public key từ server
    const keyRes = await fetch('http://localhost:3000/public-key');
    const { publicKey } = await keyRes.json();
    console.log('Got server public key');

    // Bước 2: Encrypt payload
    const encrypted = await encryptPayload(payload, publicKey);
    console.log('Encrypted:', {
        d: encrypted.d.substring(0, 40) + '...',
        k: encrypted.k.substring(0, 40) + '...',
    });

    // Bước 3: Gửi request
    const res = await fetch('http://localhost:3000/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(encrypted),
    });

    console.log(' Server response:', await res.json());
}

main().catch(console.error);

Chạy thử

# Terminal 1
node server.js

# Terminal 2
node client.js

Output

# Server log:
 Decrypted payload: { user: 'demo_user', password: 'demo_password', ... }

# Client log:
 Got server public key
 Encrypted: { d: 'abc123...', k: 'xyz789...' }
 Server response: { success: true, received: { user: 'demo_user', ... } }

Lưu ý quan trọng: Mỗi lần chạy client.js, AES key và IV hoàn toàn khác nhau — dù payload giống hệt nhau, dk trả ra luôn khác. Đây chính là cơ chế chống replay attack.


7. Tại sao cơ chế này hiệu quả?

Tấn công Kết quả
Intercept HTTPS (Burp, mitmproxy) Chỉ thấy {d: "...", k: "..."} — không có ý nghĩa
Replay attack AES key mới mỗi request → request cũ invalid
Brute force AES key AES-256 = 2^256 tổ hợp — không khả thi
Lấy được public key Chỉ dùng để mã hóa, không giải mã được
Compromise một request Forward secrecy — key khác nhau mỗi request

8. Chuẩn hóa: JWE (JSON Web Encryption)

Pattern này không mới — nó đã được chuẩn hóa trong RFC 7516 (JWE). Nếu bạn implement từ đầu, nên dùng JWE vì:

  • Đã được audit kỹ bởi cộng đồng cryptography
  • Library support đầy đủ mọi ngôn ngữ
  • Tránh các lỗi implementation phổ biến (IV reuse, padding oracle...)

Cấu trúc JWE Compact Serialization:

BASE64URL(header) . BASE64URL(encrypted_key) . BASE64URL(iv) . BASE64URL(ciphertext) . BASE64URL(tag)

Về bản chất rất giống những gì mô tả ở trên — chỉ là được chuẩn hóa và có thêm authentication tag (AEAD) chống tamper.


9. Nếu bạn là developer — Implement đúng cách

Nên làm:

  • Random key + IV mỗi request — không tái sử dụng
  • Dùng AEAD mode (AES-GCM) thay vì AES-CTR nếu có thể — có authentication tag, chống tamper
  • Kết hợp với Certificate Pinning để chống MITM hoàn toàn
  • Rotate RSA key định kỳ

Không nên:

  • Hardcode AES key trong client code
  • Dùng AES-ECB (mode yếu nhất, không dùng IV)
  • Tự thiết kế crypto scheme — dùng chuẩn đã được kiểm chứng (JWE)

Kết

Hybrid Encryption là pattern phổ biến trong fintech và banking. Hiểu cơ chế này giúp bạn:

  • Là developer: Implement bảo mật đúng cách cho product của mình
  • Là security engineer: Biết cần kiểm tra gì khi pentest ứng dụng tài chính
  • Là reviewer: Đánh giá đúng rủi ro khi review thiết kế hệ thống

Phần tiếp theo mình sẽ nói về Obfuscation / WASM — lớp bảo mật thứ hai để làm khó reverse engineering trong các banking app.


Tags: security cryptography aes rsa fintech banking hybrid-encryption nodejs


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí