Khóa API trong file DLL: đổi tên biến và NativeAOT không giấu được
App POS của BanHang nhúng khóa BHPay giả và chuỗi kết nối vào DLL; một công cụ đọc heap #US tìm 3 bí mật trong 21 ms, bản đổi tên biến và NativeAOT vẫn lộ cả 3, chỉ bản bỏ bí mật khỏi client mới về 0.
Mục lục
- 1. Vấn đề: khóa BHPay nằm thẳng trong bộ cài đặt
- 2. Mục đích: không bí mật nào còn trong bản phát hành
- 3. Cơ sở lý thuyết: chuỗi hằng sống trong heap #US
- 4. Cách giải quyết: server cấp token ngắn hạn, CI chặn bí mật
- 5. Cách cài đặt: công cụ quét #US và server token
- 6. Chứng minh: 3 bí mật ở ba bản đầu, 0 ở bản đã sửa
- 7. Kết luận
- Đọc tiếp
- Nguồn
Đọc nhanh
- Vấn đề: App bán hàng tại quầy của
BanHangnhúng khóa APIBHPaygiả và chuỗi kết nối vào DLL vàappsettings.json; ai có bộ cài đều đọc được. - Cách giải: Client không giữ bí mật dài hạn; một server nhỏ cấp token ngắn hạn theo máy và theo ca, và một công cụ quét heap
#USchặn bản build lỏng bí mật ngay trong CI. - Chứng minh: Công cụ đọc
#UStìm 3 bí mật trong 21 ms; bản đổi tên biến và bản NativeAOT vẫn lộ cả 3; bản đã sửa ra 0 bí mật bằng cả hai cách đo. - Trong .NET:
System.Reflection.Metadatađọc heap#US, trả mã thoát khác 0 đểdotnet buildtrong CI dừng lại.
1. Vấn đề: khóa BHPay nằm thẳng trong bộ cài đặt
BanHang phát hành một app bán hàng tại quầy (POS) viết bằng .NET, cài trên máy tính từng cửa hàng. App gọi ví BHPay để thu tiền, nên nó giữ một khóa API, và nó nối thẳng vào SQL Server của cửa hàng, nên nó giữ một chuỗi kết nối. Bản đầu đặt cả hai vào mã nguồn và appsettings.json:
{
"ConnectionStrings": { "BanHang": "Server=sql-ch042…;Password=GiaMatKhau#CH042" },
"BHPay": { "ApiKey": "bhpay_test_sk_Ft6Ns1Ye8Uj3Gq9Cw2Ra7Lm4" }
}
Mọi khóa và mật khẩu trong bài là giả, đặt tên rõ: bhpay_test_…, GiaMatKhau…. Một kỹ thuật viên chép thư mục cài đặt sang USB để sửa máy khác. Trong thư mục đó có appsettings.json đọc được bằng Notepad, và một file BanHangPos.dll. Nhiều người nghĩ .dll là nhị phân nên an toàn; thực ra chuỗi hằng trong DLL .NET đọc ra còn nhanh hơn, không cần chạy app. Một khóa BHPay rò là rủi ro tiền; một chuỗi kết nối rò là cả database cửa hàng.
Bài này dịch ngược chính bản build của mình để đo xem bí mật lộ tới đâu, và kiểm bốn cách thường được đề xuất để giấu chúng. Chỉ làm trên phần mềm và dữ liệu bạn có quyền.
2. Mục đích: không bí mật nào còn trong bản phát hành
- Một công cụ liệt kê mọi chuỗi trong DLL và báo đúng số bí mật khớp mẫu khóa hoặc chuỗi kết nối; chạy được trong CI và trả mã thoát khác 0 khi còn bí mật.
- Đo lại trên bản đổi tên biến (obfuscation) và bản NativeAOT: nếu bí mật vẫn tìm được thì hai cách đó không phải lời giải.
- Bản đã sửa: 0 bí mật bằng cả hai cách đo (đọc
#USvà quét byte thô). - Ngoài phạm vi: chống dịch ngược hoàn toàn. Không có cách nào giấu được một bí mật đã nằm trên máy người khác; mục tiêu là đừng đặt nó ở đó.
3. Cơ sở lý thuyết: chuỗi hằng sống trong heap #US
Một assembly .NET không phải mã máy. Nó gồm mã trung gian IL và một khối metadata mô tả mọi kiểu, phương thức và hằng, theo chuẩn ECMA-335. Metadata chia thành nhiều heap; chuỗi hằng người dùng nằm trong heap tên #US (user strings), lưu dạng UTF-16. Mỗi chuỗi là một blob: một tiền tố độ dài nén, rồi các byte UTF-16, rồi một byte cờ.
Hệ quả rút ra từ cấu trúc này, mỗi điều ứng với một cách giấu bí mật thường gặp:
- Đọc lại là chuyện vặt.
System.Reflection.Metadata.MetadataReaderduyệt heap#USvàGetUserStringtrả về đúng chuỗi gốc. Không cần giải mã, không cần chạy app. - ILSpy dựng lại C#. Từ IL và metadata, ILSpy khôi phục mã C# gần như mã gốc, gồm cả giá trị chuỗi hằng.
- Đổi tên biến không đụng chuỗi. Obfuscation kiểu đổi tên chỉ sửa bảng tên kiểu và thành viên (
CauHinhthànha,KhoaApiDuPhongthànhA). Nội dung heap#USgiữ nguyên, nên chuỗi vẫn đọc ra y hệt. - NativeAOT bỏ
#USnhưng không bỏ chuỗi. Bản biên dịch sẵn ra mã máy không còn metadata#US, nên công cụ đọc#USthấy 0 chuỗi. Nhưng chuỗi hằng vẫn được nhúng vào file thực thi (vùng dữ liệu chỉ đọc), vẫn là UTF-16, nên một lần quét byte thô tìm lại được.
Nói cách khác, ba trong bốn cách chỉ làm chuỗi khó thấy hơn một chút, không làm nó biến mất. Chỉ cách thứ tư, không đặt bí mật vào client, mới đổi được kết quả đo.
4. Cách giải quyết: server cấp token ngắn hạn, CI chặn bí mật
| Cách | Ưu | Nhược | Khi nào dùng |
|---|---|---|---|
| Đổi tên biến (obfuscation) | Khó đọc luồng logic hơn | Không giấu được chuỗi; đo lại vẫn thấy khóa | Chống sao chép logic, không phải giấu bí mật |
| Mã hóa chuỗi trong app | Quét chuỗi ngây thơ thấy rác | Khóa giải mã cũng phải nằm trong app; chạy hoặc gỡ rối là lấy được | Làm chậm người xem lướt, không chặn được |
| NativeAOT | Không còn #US, không dựng lại C# dễ |
Chuỗi vẫn trong file thực thi; quét byte thô vẫn thấy | Khi cần khởi động nhanh, không phải để giấu |
| Bỏ bí mật khỏi client | Không còn gì để rò trên máy cửa hàng | Phải có server cấp quyền | Mọi bí mật dùng chung cho nhiều máy |
BanHang chọn cách cuối. Client không giữ khóa BHPay dài hạn. Khi máy POS khởi động, nó xin một token ngắn hạn từ một server nội bộ; token sống 15 phút, gắn với mã máy và ca bán hàng. Khóa gốc chỉ nằm ở server. Chuỗi kết nối không nhúng vào mã mà đọc từ biến môi trường do máy cấu hình.
sequenceDiagram participant POS as Máy POS (client) participant CAP as Server cấp token participant BH as BHPay POS->>CAP: Xin token cho máy CH042-POS1 CAP-->>POS: Token ký, hết hạn sau 15 phút POS->>BH: Gọi API kèm token ngắn hạn Note over POS: Không giữ khóa gốc, không ghi ra đĩa
Để lỗi không quay lại ở bản build sau, một công cụ quét heap #US của mọi DLL trong thư mục ra, lọc theo mẫu khóa và chuỗi kết nối, và trả mã thoát khác 0 khi còn bí mật. Đặt nó thành một bước trong CI thì bản nào lỡ nhúng lại khóa sẽ làm hỏng build thay vì ra tới cửa hàng. Cùng công cụ đó quét được cả file NativeAOT bằng nhánh quét byte thô.
5. Cách cài đặt: công cụ quét #US và server token
Môi trường đo: .NET SDK 10.0.401, runtime 10.0.12, Windows 11, Intel Core Ultra 5 125U. Mọi đoạn là file-based app, chạy bằng dotnet run.
Lõi của công cụ là vòng duyệt heap #US. GetHeapSize cho kích thước heap; mỗi blob tự cho biết độ dài để nhảy sang blob kế. Phần này không cần gói ngoài, System.Reflection.Metadata có sẵn trong .NET:
int size = mr.GetHeapSize(HeapIndex.UserString); // heap #US: blob nối nhau, offset 0 là chuỗi rỗng
for (int offset = 1; offset < size;)
{
var handle = MetadataTokens.UserStringHandle(offset);
string s = mr.GetUserString(handle);
int n = s.Length * 2 + 1; // N byte dữ liệu = UTF-16 + 1 byte cờ
offset += (n < 0x80 ? 1 : n < 0x4000 ? 2 : 4) + n;
if (s.Length > 0) Khop(s); // so với mẫu khóa và chuỗi kết nối
}
Với file không có metadata (NativeAOT), công cụ quét byte thô: giải cả file theo UTF-16 ở hai lề byte và theo UTF-8, rồi chạy cùng mẫu. Toàn bộ công cụ, gồm cả nhánh thô và cách đếm bí mật phân biệt, chạy bằng dotnet run QuetBiMat.cs -- <thư mục>:
QuetBiMat.cs: quét heap #US và byte thô, trả mã thoát 1 khi còn bí mật
// QuetBiMat.cs — tìm bí mật đóng gói trong bản build .NET.
// - Assembly IL (.dll/.exe có metadata): đọc đúng heap #US (ECMA-335 II.24.2.4).
// - File native (bản NativeAOT, không có metadata): quét byte thô UTF-16 và UTF-8.
// Lọc theo mẫu khóa API và chuỗi kết nối, trả mã thoát 1 khi tìm thấy để chặn build trong CI.
// Chạy: dotnet run QuetBiMat.cs -- <thư mục>
using System.Diagnostics;
using System.Reflection.Metadata;
using System.Reflection.Metadata.Ecma335;
using System.Reflection.PortableExecutable;
using System.Text;
using System.Text.RegularExpressions;
string thuMuc = args.FirstOrDefault(a => !a.StartsWith("--")) ?? ".";
(string Ten, Regex Mau)[] mau =
[
("Khóa API BHPay", new Regex(@"bhpay_(test|live)_[a-z]+_[A-Za-z0-9]{16,}", RegexOptions.IgnoreCase)),
("Chuỗi kết nối", new Regex(@"(Server|Data Source)\s*=[^""]*(Password|Pwd)\s*=[^""\s;]{3,}", RegexOptions.IgnoreCase)),
];
var sw = Stopwatch.StartNew();
int soFile = 0, soChuoi = 0;
// gom theo chuỗi bí mật phân biệt: một chuỗi khớp nhiều mẫu vẫn là một bí mật
var tim = new Dictionary<string, (string File, SortedSet<string> Nhom)>();
void Khop(string file, string s)
{
foreach (var (ten, re) in mau)
if (re.IsMatch(s))
{
if (!tim.TryGetValue(s, out var e)) tim[s] = e = (file, new SortedSet<string>());
e.Nhom.Add(ten);
}
}
foreach (var file in Directory.EnumerateFiles(thuMuc, "*.dll").Concat(Directory.EnumerateFiles(thuMuc, "*.exe")))
{
soFile++;
using var fs = File.OpenRead(file);
using var pe = new PEReader(fs);
string ten = Path.GetFileName(file);
if (pe.HasMetadata)
{
var mr = pe.GetMetadataReader();
int size = mr.GetHeapSize(HeapIndex.UserString);
for (int offset = 1; offset < size;)
{
var handle = MetadataTokens.UserStringHandle(offset);
string s = mr.GetUserString(handle);
int n = s.Length * 2 + 1;
offset += (n < 0x80 ? 1 : n < 0x4000 ? 2 : 4) + n;
if (s.Length == 0) continue;
soChuoi++;
Khop(ten, s);
}
}
else
{
byte[] b = File.ReadAllBytes(file);
foreach (var text in new[] { Encoding.Unicode.GetString(b), Encoding.Unicode.GetString(b, 1, b.Length - 1), Encoding.UTF8.GetString(b) })
foreach (var (_, re) in mau)
foreach (Match mt in re.Matches(text)) Khop(ten, mt.Value);
}
}
sw.Stop();
foreach (var (s, e) in tim) Console.WriteLine($"[{string.Join(", ", e.Nhom)}] {e.File}: {s}");
Console.WriteLine($"Quét {soFile} file, {soChuoi} chuỗi #US, {tim.Count} bí mật trong {sw.Elapsed.TotalMilliseconds:0} ms.");
return tim.Count == 0 ? 0 : 1;
Server cấp token là một minimal API. Nó giữ khóa ký trong biến môi trường của server, dựng token gồm một payload (mã máy, ca, hạn dùng) và một chữ ký HMAC, rồi trả về cho client:
app.MapGet("/token", (string may) =>
{
var hetHan = DateTime.UtcNow.AddMinutes(15);
string ca = DateTime.UtcNow.ToString("yyyyMMdd") + "-" + (DateTime.UtcNow.Hour < 6 ? "T" : "S");
var payload = JsonSerializer.Serialize(new Payload(may, ca, (long)(hetHan - epoch).TotalSeconds), AppJson.Default.Payload);
string p64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
string sig = Convert.ToBase64String(HMACSHA256.HashData(khoaKy, Encoding.UTF8.GetBytes(p64)));
return Results.Json(new TokenNganHan($"{p64}.{sig}", hetHan), AppJson.Default.TokenNganHan);
});
Cả server và client dùng JSON source-gen (JsonSerializerContext) thay cho JSON phản chiếu, nên chúng publish được cả bản NativeAOT. Client bỏ mọi hằng bí mật: chuỗi kết nối đọc từ BANHANG_CS, token xin lúc chạy.
CapToken.cs và client đã sửa, chạy bằng dotnet run
// ----- CapToken.cs: server cấp token ngắn hạn. Chạy: dotnet run CapToken.cs -----
#:sdk Microsoft.NET.Sdk.Web
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJson.Default));
var app = builder.Build();
byte[] khoaKy = Encoding.UTF8.GetBytes(Environment.GetEnvironmentVariable("BHPAY_TOKEN_KEY") ?? "khoa-ky-server-chay-thu");
var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
app.MapGet("/token", (string may) =>
{
var hetHan = DateTime.UtcNow.AddMinutes(15);
string ca = DateTime.UtcNow.ToString("yyyyMMdd") + "-" + (DateTime.UtcNow.Hour < 6 ? "T" : "S");
var payload = JsonSerializer.Serialize(new Payload(may, ca, (long)(hetHan - epoch).TotalSeconds), AppJson.Default.Payload);
string p64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
string sig = Convert.ToBase64String(HMACSHA256.HashData(khoaKy, Encoding.UTF8.GetBytes(p64)));
return Results.Json(new TokenNganHan($"{p64}.{sig}", hetHan), AppJson.Default.TokenNganHan);
});
app.Run("http://127.0.0.1:5099");
record Payload(string May, string Ca, long Exp);
record TokenNganHan(string Token, DateTime HetHan);
[JsonSerializable(typeof(Payload))]
[JsonSerializable(typeof(TokenNganHan))]
partial class AppJson : JsonSerializerContext;
// ----- BanHangPos.cs (đã sửa): client không giữ bí mật. Chạy: dotnet run BanHangPos.cs -----
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
string chuoiKetNoi = Environment.GetEnvironmentVariable("BANHANG_CS") ?? ""; // máy cửa hàng đặt, không nhúng
string maMay = Environment.GetEnvironmentVariable("POS_MAY") ?? "CH000-POS0";
var cap = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5099/") };
var token = await cap.GetFromJsonAsync($"token?may={Uri.EscapeDataString(maMay)}", AppJson.Default.TokenNganHan)
?? throw new InvalidOperationException("Không xin được token.");
var bhpay = new HttpClient { BaseAddress = new Uri("https://api.bhpay.example/") };
bhpay.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
Console.WriteLine($"POS sẵn sàng, có chuỗi kết nối: {chuoiKetNoi.Length > 0}, token hết hạn lúc {token.HetHan:HH:mm:ss}");
record TokenNganHan(string Token, DateTime HetHan);
[JsonSerializable(typeof(TokenNganHan))]
partial class AppJson : JsonSerializerContext;
Token trả về giải ra được, cho thấy nó mang đúng máy và ca, hết hạn sau 15 phút, không chứa khóa gốc:
Token: eyJNYXkiOiJDSDA0Mi1QT1MxIiwiQ2EiOiIyMDI2MTAwNC1TIiwiRXhwIjoxNzkxMTEwODY1fQ==.qnig06RQ…
payload giải base64: {"May":"CH042-POS1","Ca":"20261004-S","Exp":1791110865} → hết hạn 10:47:45Z
6. Chứng minh: 3 bí mật ở ba bản đầu, 0 ở bản đã sửa
Dựng một app POS mẫu lỏng bí mật, publish bốn bản trên cùng máy, rồi đo. Bản gốc publish thường (IL). Bản đổi tên dùng Obfuscar 2.2.50 chỉ bật đổi tên, tắt mã hóa chuỗi. Bản NativeAOT publish mặc định của file-based app. Bản đã sửa là client ở mục 5.
Chạy công cụ #US trên bản gốc: nó tìm đủ 3 bí mật phân biệt trong 21 ms (khóa API, khóa ký, và chuỗi kết nối có mật khẩu). Chạy ilspycmd 11.1.0.9782 trên cùng DLL: nó dựng lại C# sau 2.426 ms, và các bí mật hiện nguyên văn trên 5 dòng mã. Cả hai công cụ đều đọc ra, công cụ #US nhanh hơn khoảng mười lần vì nó chỉ cần chuỗi, không dựng lại cả cây cú pháp.
| Công cụ | Việc làm | Thời gian (trung vị) | Bí mật đọc ra |
|---|---|---|---|
Công cụ #US (QuetBiMat) |
Liệt kê chuỗi #US, lọc mẫu |
231 ms (quét lõi 21 ms) | 3 |
ilspycmd 11.1.0.9782 |
Dựng lại toàn bộ C# | 2.426 ms | 3 (hiện trên 5 dòng) |
Đo lại trên ba bản còn lại cho thấy đổi tên biến và NativeAOT không phải lời giải. Bản đổi tên: Obfuscar đổi CauHinh thành lớp a, BHPayClient thành B, trường KhoaApiDuPhong thành A, nhưng chuỗi còn nguyên, nên công cụ #US vẫn ra 3. Bản NativeAOT: công cụ đọc 0 chuỗi #US vì file không còn metadata, nhưng nhánh quét byte thô tìm lại cả 3 bí mật trong file thực thi (khóa bhpay_test_sk_… nằm ở offset khoảng 2,77 triệu byte, dạng UTF-16). Chỉ bản đã sửa ra 0 bằng cả hai cách.
đổi tên biến: CauHinh → a, BHPayClient → B, KhoaApiDuPhong → A
nhưng: public const string A = "bhpay_test_sk_9Qm4ZtR7vLx2Kp8NwB3sHc6D";
Chỉ bỏ bí mật khỏi client mới đưa cả hai cách đo về 0
Bảng số liệu
| Công cụ #US | Quét byte thô | |
|---|---|---|
| Bản gốc | 3 bí mật | 3 bí mật |
| Đổi tên biến | 3 bí mật | 3 bí mật |
| NativeAOT | 0 bí mật | 3 bí mật |
| Đã bỏ bí mật | 0 bí mật | 0 bí mật |
Cột "NativeAOT" là chỗ dễ hiểu sai nhất: con số 0 của công cụ #US không nghĩa là bí mật biến mất, chỉ nghĩa là công cụ đó nhìn sai chỗ. Vì vậy cổng CI dùng cả hai nhánh: với bản NativeAOT nó vẫn báo 3 và làm hỏng build. Chỉ khi client không còn hằng bí mật nào thì cả hai nhánh mới cùng ra 0, và đó là bản duy nhất an toàn mang ra cửa hàng.
7. Kết luận
Một bí mật nhúng vào bản build client là một bí mật đã công khai với mọi người cầm bộ cài. Đổi tên biến, mã hóa chuỗi hay NativeAOT chỉ đổi số bước để lấy, không đổi việc lấy được. Cách chắc chắn là không đặt bí mật vào client.
Trong dự án .NET của bạn:
- Bỏ khóa và chuỗi kết nối khỏi mã và
appsettings.jsoncủa client; đọc chuỗi kết nối từ biến môi trường hoặc kho bí mật của máy, và xin token ngắn hạn từ một server nhưCapTokenở mục 5. - Thêm một bước CI quét heap
#USbằngSystem.Reflection.Metadata(MetadataReader.GetUserString), kèm nhánh quét byte thô cho file NativeAOT, và cho nó trả mã thoát khác 0 khi còn bí mật. - Nếu một khóa thật đã từng nằm trong bản build cũ, coi như nó đã lộ: xoay khóa đó, đừng chỉ gỡ khỏi bản mới.
Những chỗ hay hiểu sai
- "File
.dlllà nhị phân nên an toàn." Chuỗi hằng trong.dllđọc ra bằng một vòng lặp ngắn, không cần chạy app. - "Obfuscation giấu được bí mật." Đổi tên biến không đụng heap
#US; chuỗi vẫn nguyên văn. - "NativeAOT không còn chuỗi vì không còn metadata." Chuỗi hằng vẫn nằm trong file thực thi, quét byte thô vẫn thấy.
Đọc tiếp
- Lưu mật khẩu: khi buộc phải giữ bí mật ở server, băm và salt thế nào cho đúng.
- Xác thực webhook thanh toán: khóa ký webhook cũng là một bí mật không được để lọt ra client.
- Idempotency key: token ngắn hạn và khóa idempotency đều do một bên tin cậy sinh ra.
- Chữ số kiểm tra: một ví dụ khác đọc cấu trúc nhị phân theo chuẩn, thay vì đoán.
Nguồn
- ECMA-335, Common Language Infrastructure (CLI), Partition II §24.2.4 "#US heap". Đọc 2026-10-04.
- Microsoft Learn, System.Reflection.Metadata.MetadataReader và GetUserString. Đọc 2026-10-04.
- Microsoft Learn, Native AOT deployment và File-based apps. Đọc 2026-10-04.
- ILSpy / ICSharpCode.Decompiler 11.1.0.9782, và Obfuscar 2.2.50. Đọc 2026-10-04.