SecLab
Server-sideĐầy đủ

Information disclosure

A01CWE-200
01

Là gì

Rò rỉ thông tin là khi ứng dụng tiết lộ những gì kẻ tấn công cần để tấn công tiếp: stack trace, phiên bản framework, đường dẫn nội bộ, tên bảng, khoá API trong bundle JavaScript, hay chỉ là sự khác nhau giữa hai thông báo lỗi. Bản thân nó ít khi là sự cố; nó là bước một của mọi sự cố khác.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

Đây là topic dễ bị coi nhẹ nhất, và lý do là mức độ của nó luôn được đánh giá đơn lẻ: "chỉ là một stack trace" — CVSS thấp, đóng ticket. Nhưng giá trị của nó nằm ở chỗ nó biến việc đoán thành việc biết:

  • Biết phiên bản framework → tra đúng CVE thay vì thử hàng trăm payload
  • Biết tên bảng và tên cột từ một lỗi SQL → viết UNION SELECT đúng ngay lần đầu
  • Biết đường dẫn tuyệt đối từ một stack trace → payload path traversal chính xác
  • Biết email nào tài khoản → password spraying vào một danh sách thật, không phải danh sách đoán

Ba đặc điểm khiến nó phổ biến hơn mọi topic khác:

  • Nó là mặc định. Rò rỉ là cái xảy ra khi không ai làm gì cả: ASPNETCORE_ENVIRONMENT đặt sai, .git/ đi theo lúc deploy, Server: header của framework, sourceMappingURL trong bundle production.
  • Nó không có triệu chứng. Không có gì hỏng, không có exception, không có ai báo lỗi. Ứng dụng chạy hoàn toàn bình thường trong lúc rò.
  • Kênh rò thường là chính thứ giúp bạn debug. Cùng một stack trace vừa cứu bạn lúc 2 giờ sáng vừa là bản đồ cho người khác. Nên bản vá không phải "bỏ thông tin đi" mà là đổi hướng nó: vào log, không vào response.
03

Cơ chế hoạt động

Không có một cơ chế; có năm kênh, và biết năm kênh này là cách đi tìm chúng có hệ thống.

Nguồn sơ đồ
flowchart LR    A[Kẻ tấn công] --> C1["① Thông báo lỗi<br/>stack trace, lỗi SQL"]    A --> C2["② Header response<br/>Server, X-Powered-By, X-AspNet-Version"]    A --> C3["③ File lộ ra<br/>.git/, .env, .DS_Store, backup~, /debug"]    A --> C4["④ Client-side<br/>source map, khoá API, comment, endpoint chưa dùng"]    A --> C5["⑤ Kênh phụ<br/>chênh lệch thời gian, khác biệt mã lỗi"]    C1 --> M["Bản đồ hệ thống"]    C2 --> M    C3 --> M    C4 --> M    C5 --> M    M --> X["Tấn công có mục tiêu"]
KênhVí dụ cụ thểTìm nó bằng cách nào
① LỗiNpgsql.PostgresException: relation "core.app_user" does not exist at /src/app/Repositories/UserRepository.cs:42Gửi input sai kiểu vào mọi endpoint
② HeaderServer: Kestrel, X-Powered-By: ASP.NET, X-AspNet-Version: 8.0.4curl -I
③ File/.git/config, /.env, /appsettings.Production.json, /backup.sql, /swagger ở productionWordlist
④ Client//# sourceMappingURL=main.js.map, const STRIPE_KEY="sk_live_…"Đọc bundle
⑤ Kênh phụEmail có tài khoản: 780ms; không có: 60msĐo thời gian

Kênh ⑤ đáng nói riêng vì nó là kênh duy nhất không thể "bỏ thông tin đi" — thông tin nằm trong hành vi, không trong nội dung. Cơ chế: hàm đăng nhập kiểm email trước, thấy không có thì trả về ngay; thấy có thì chạy Argon2 để so mật khẩu. Argon2 cố tình chậm (~700ms), nên chênh lệch thời gian chính là câu trả lời cho câu hỏi "email này có tài khoản không" — và nó đúng ngay cả khi hai response giống nhau từng byte.

Biến thể của cùng kênh đó, không cần đo thời gian: POST /register với email đã tồn tại trả 409 "Email already in use", còn email mới trả 201. Đó là một API liệt kê người dùng, được ghi trong tài liệu.

Mô tả sơ đồ: Sơ đồ cho thấy kẻ tấn công thu thập thông tin qua năm kênh song song: thông báo lỗi chứa stack trace và lỗi SQL; header response như Server và X-Powered-By; file lộ ra như .git, .env, .DS_Store và trang swagger; dữ liệu phía client như source map, khoá API và endpoint chưa dùng; và kênh phụ như chênh lệch thời gian phản hồi hay khác biệt mã lỗi. Cả năm kênh cùng đóng góp vào một bản đồ hệ thống, và bản đồ đó dẫn tới một cuộc tấn công có mục tiêu thay vì đoán mò.

04

Ví dụ cụ thể

Bốn request vô hại, và cùng nhau chúng là một bản đồ.

HTTP
# ① Gửi sai kiểu để lấy stack trace. Không cần payload nào.GET /api/orders/abc HTTP/1.1 HTTP/1.1 500 Internal Server Error System.FormatException: The input string 'abc' was not in a correct format.   at SecLab.Api.Controllers.OrdersController.Get(Int64 id) in /src/app/…/OrdersController.cs:line 34   at Npgsql.NpgsqlConnection.Open() — Host=prod-db-01.internal;Database=seclab;Username=postgres

Ba thứ vừa lộ: đường dẫn tuyệt đối trên server, tên host DB nội bộ, và user DB là postgres (superuser).

HTTP
# ② Header. Một lệnh, và bây giờ đã biết tra CVE nào.HEAD / HTTP/1.1 HTTP/1.1 200 OKServer: KestrelX-Powered-By: ASP.NETX-AspNet-Version: 8.0.4
HTTP
# ③ .git đi theo lúc deploy. Đây là toàn bộ source code và toàn bộ lịch sử commit.GET /.git/config HTTP/1.1 HTTP/1.1 200 OK[remote "origin"]  url = https://x-token-auth:ATBB3xK…@bitbucket.org/acme/seclab.git

Và trong URL đó có một token truy cập repository.

Shell
# ⑤ Kênh phụ. Hai response giống nhau từng byte; thời gian thì không.for e in alice@acme.com khong-ton-tai@acme.com; do  curl -s -o /dev/null -w "$e  %{time_total}s\n" -X POST https://app.example.com/api/login \    -d "{\"email\":\"$e\",\"password\":\"x\"}" -H 'Content-Type: application/json'done# alice@acme.com          0.781s   ← có tài khoản (Argon2 đã chạy)# khong-ton-tai@acme.com  0.058s   ← không có (trả về ngay)
C#Ba kênh rò trong ba đoạn, và không đoạn nào trông như một lỗ hổng.
// ── Kênh ① · Thông báo lỗi ──────────────────────────────────────────────────var app = builder.Build(); // ❌ Không có điều kiện IsDevelopment(). Một biến môi trường đặt sai ở MỘT môi//    trường là đủ, và không dòng code nào ở đây "có lỗi" nên không test nào đỏ.app.UseDeveloperExceptionPage(); app.MapControllers(); // ── Kênh ① · và cùng lỗi ở tầng handler ─────────────────────────────────────[HttpGet("/api/orders/{id:long}")]public async Task<IActionResult> Get(long id, CancellationToken ct){    try    {        return Ok(await _orders.GetByIdAsync(id, _currentUser.UserId, ct));    }    catch (Exception ex)    {        // ❌ ex.ToString() là stack trace ĐẦY ĐỦ: đường dẫn tuyệt đối trên server,        //    tên assembly, số dòng, và với lỗi Npgsql thì cả chuỗi kết nối.        return StatusCode(500, new { error = ex.ToString() });    }} // ── Kênh ⑤ · Liệt kê người dùng qua thời gian ───────────────────────────────public async Task<LoginResult> LoginAsync(string email, string password, CancellationToken ct){    var user = await _users.FindByEmailAsync(email, ct);     // ❌ TRẢ VỀ SỚM. Thông báo lỗi ở hai nhánh giống nhau từng byte, nên bản vá này    //    TRÔNG như đã đúng. Nhưng nhánh này về ngay (~50ms) còn nhánh dưới chạy    //    Argon2 (~700ms) — và 700ms là câu trả lời rõ ràng cho "email này có tài    //    khoản không".    if (user is null)        return LoginResult.Invalid;     if (!_hasher.Verify(user.PasswordHash, password))        return LoginResult.Invalid;     return LoginResult.Success(user);}
05

Chuyện đã xảy ra

Uber, 2016 — 57 triệu người dùng. Điểm vào là một khoá AWS trong một repository GitHub riêng tư mà nhân viên vô tình đưa lên; từ đó kẻ tấn công đọc được backup trên S3. Đây là kênh ④ ở dạng mà mọi người coi là "không phải lỗ hổng ứng dụng" — và nó là điểm vào của một trong những sự cố được biết nhiều nhất thập kỷ, cộng thêm một bản án hình sự cho CSO vì che giấu.

Cả họ sự cố .git lộ ra công khai. Nhiều nghiên cứu quét Internet tìm /.git/HEAD trả 200 và tìm ra hàng trăm nghìn site — mỗi cái là toàn bộ source code cộng toàn bộ lịch sử commit, và lịch sử commit là nơi những secret đã bị "xoá" vẫn còn nguyên. Đáng nhớ vì nguyên nhân luôn là một dòng trong Dockerfile: COPY . .

Kênh ⑤ trong thực tế: nhiều báo cáo bug bounty công khai về liệt kê người dùng qua chênh lệch thời gian ở endpoint đăng nhập và reset mật khẩu. Không sự cố nào lớn, nhưng nó là thứ biến một danh sách email mua được thành một danh sách mục tiêu đã xác nhận cho credential stuffing.

06

Cách phòng chống

Lớp 1

Đổi hướng thông tin, không bỏ nó đi — correlation id là bản vá

bắt buộc

Khối 2 nói rằng kênh rò thường chính là thứ giúp bạn debug. Nên bản vá đúng không phải "trả về thông báo lỗi chung chung" — nó là giữ nguyên toàn bộ chi tiết, gửi vào log, và cho client một id để tra.

Response chứa: một mã lỗi ổn định (ER_ORDER_NOT_FOUND), một câu người đọc hiểu được, và một correlation id. Log chứa: stack trace đầy đủ, câu truy vấn, giá trị đầu vào, cùng correlation id đó. Hỗ trợ nhận id từ người dùng và tìm ra đúng dòng log — nên không mất gì về khả năng vận hành.

Trong ASP.NET Core cụ thể:

  • Một exception filter/middleware ở một chỗ duy nhất biến mọi exception thành envelope đó. Đây là điểm quan trọng: một try/catch trong từng controller là 40 chỗ để làm sai.
  • app.UseDeveloperExceptionPage() chỉ trong IsDevelopment(), và kiểm điều đó bằng test, không bằng niềm tin — biến môi trường đặt sai là nguyên nhân thật của phần lớn trường hợp.
  • Validation error nói trường nào sai, không nói giá trị nào (xem topic xxe: giá trị echo lại chính là kênh rò).

ProblemDetails (RFC 9457) là định dạng chuẩn cho việc này, và nó có sẵn chỗ cho traceId — dùng nó thay vì tự nghĩ ra hình dạng.

C# · Layer 1Chi tiết vào log kèm traceId; response chỉ có mã lỗi và id. Và hash giả cho nhánh không tìm thấy.
/// <summary>/// Một chỗ duy nhất biến mọi exception thành response. Middleware chứ không phải/// try/catch trong từng controller: 40 controller là 40 chỗ để làm sai, và chỗ thứ/// 41 sẽ thiếu.////// Nguyên tắc là ĐỔI HƯỚNG, không phải BỎ ĐI: toàn bộ chi tiết vẫn được ghi, chỉ là/// vào log thay vì vào response. Hỗ trợ nhận traceId từ người dùng và tìm ra đúng/// dòng log — nên không mất gì về khả năng vận hành./// </summary>public sealed class ExceptionEnvelopeMiddleware(RequestDelegate next, ILogger<ExceptionEnvelopeMiddleware> log){    public async Task InvokeAsync(HttpContext ctx)    {        try        {            await next(ctx);        }        catch (Exception ex)        {            // TraceId của Activity hiện tại: nó đã có trong mọi dòng log của request            // này nhờ OpenTelemetry, nên không cần sinh id riêng.            var traceId = Activity.Current?.TraceId.ToString() ?? ctx.TraceIdentifier;             // TOÀN BỘ chi tiết vào log — stack trace, inner exception, đường dẫn.            log.LogError(ex, "Unhandled exception on {Method} {Path} (trace {TraceId})",                ctx.Request.Method, ctx.Request.Path, traceId);             var (status, code) = ex switch            {                NotFoundException nf            => (404, nf.ErrorCode),                ApplicationGeneralException age  => (422, age.ErrorCode),                OperationCanceledException      => (499, "ER_CLIENT_CLOSED"),                _                               => (500, "ER_INTERNAL"),            };             ctx.Response.StatusCode = status;            ctx.Response.ContentType = "application/problem+json";             // RFC 9457. Ba thứ và chỉ ba thứ: mã lỗi ổn định để client xử lý, một            // câu người đọc hiểu được, và traceId để tra. KHÔNG có ex.Message —            // message của một PostgresException chứa tên bảng và tên cột.            await ctx.Response.WriteAsJsonAsync(new ProblemDetails            {                Status = status,                Title = code,                Detail = status == 500 ? "An unexpected error occurred." : SafeDetail(ex),                Extensions = { ["traceId"] = traceId },            });        }    }     /// <summary>    /// Chỉ message của exception NGHIỆP VỤ được ra ngoài — chúng do ta viết và ta    /// biết chúng không chứa gì. Message của exception hạ tầng thì không bao giờ.    /// </summary>    private static string? SafeDetail(Exception ex) =>        ex is NotFoundException or ApplicationGeneralException ? ex.Message : null;} // Program.cs — DeveloperExceptionPage CHỈ ở Development, và khối 7 có một test// khẳng định điều đó trên môi trường Production.if (app.Environment.IsDevelopment()){    app.UseDeveloperExceptionPage();    app.MapScalarApiReference();      // Swagger/Scalar cũng chỉ ở đây}else{    app.UseMiddleware<ExceptionEnvelopeMiddleware>();} // Kênh ② — bỏ header nhận diện. Một dòng.builder.WebHost.ConfigureKestrel(o => o.AddServerHeader = false); // ── Kênh ⑤ · Hằng thời gian ─────────────────────────────────────────────────public sealed class LoginService{    /// <summary>    /// Hash của một mật khẩu ngẫu nhiên, sinh MỘT lần lúc khởi động với ĐÚNG tham    /// số Argon2 mà hash thật dùng. Cùng tham số là điều kiện để thời gian bằng nhau —    /// một hash giả với memory cost thấp hơn vẫn để lại chênh lệch đo được.    /// </summary>    private static readonly string DummyHash =        new PasswordHasher().Hash(Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));     public async Task<LoginResult> LoginAsync(string email, string password, CancellationToken ct)    {        var user = await _users.FindByEmailAsync(email, ct);         // KHÔNG trả về sớm. Không tìm thấy user thì vẫn chạy phép so hash lên        // DummyHash, nên hai nhánh tốn thời gian như nhau.        //        // Task.Delay KHÔNG thay được chỗ này: nó để lại phương sai đo được, và nó        // làm chậm cả request thật thay vì làm hai nhánh giống nhau.        var hash = user?.PasswordHash ?? DummyHash;        var ok = _hasher.Verify(hash, password);         // Và kết quả chỉ đúng khi CẢ HAI đúng — user tồn tại và hash khớp.        if (user is null || !ok)        {            // Ghi log để phát hiện (lớp 3) — email vào log, không vào response.            _log.LogInformation("Failed login for {Email}", email);            return LoginResult.Invalid;        }         return LoginResult.Success(user);    }}
YAML · Layer 1Kênh ③: multi-stage build là bản vá, .dockerignore và proxy là lưới thứ hai và ba.
# ── Dockerfile · multi-stage là BẢN VÁ, không phải tối ưu kích thước ─────────dockerfile: |  # Stage build: có source, có .git nếu ai đó copy vào, có mọi thứ. Không sao —  # stage này không bao giờ được deploy.  FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build  WORKDIR /src  COPY . .  RUN dotnet publish src/SecLab.Api -c Release -o /app/publish   # Stage runtime: CHỈ output của publish. Không có .git/, không có .env, không có  # appsettings.Development.json, không có file test — vì không có COPY . . nào ở đây.  #  # Đây là dòng thay thế cho mọi phép kiểm "đã xoá .git chưa": thứ chưa bao giờ được  # copy vào thì không cần xoá.  FROM mcr.microsoft.com/dotnet/aspnet:8.0  WORKDIR /app  COPY --from=build /app/publish .   # Không phải root, và root filesystem chỉ đọc — xem topic command-injection.  USER $APP_UID  ENTRYPOINT ["dotnet", "SecLab.Api.dll"] # ── .dockerignore · lưới THỨ HAI ────────────────────────────────────────────# Nó không thay được multi-stage build: nếu Dockerfile có COPY . . vào stage runtime# thì một dòng thiếu ở đây là đủ để .git đi theo. Nó ở đây để stage build cũng gọn# và để build cache không vỡ mỗi lần đổi README.dockerignore: |  .git  .gitignore  .env  .env.*  **/appsettings.Development.json  **/appsettings.Local.json  **/node_modules  **/bin  **/obj  **/*.md  **/.DS_Store  tests/ # ── nginx · lưới THỨ BA ─────────────────────────────────────────────────────# Cho ngày có người thêm một static file handler mới, hoặc mount một volume vào# thư mục được phục vụ.nginx: |  # Mọi đường dẫn bắt đầu bằng dấu chấm: .git, .env, .DS_Store, .aws, .ssh  location ~ /\. {      deny all;      return 404;      # 404 chứ không 403 — 403 xác nhận là file có thật  }   # Backup và file tạm mà editor để lại  location ~* \.(sql|bak|old|swp|tmp|log)$ {      return 404;  }   # Header nhận diện của tầng proxy  server_tokens off;  proxy_hide_header X-Powered-By;  proxy_hide_header X-AspNet-Version;
Lớp 1b

Response giống hệt nhau VÀ hằng thời gian ở mọi endpoint nói về danh tính

bắt buộc

Bản vá cho kênh ⑤, và nó có hai nửa — nửa thứ hai gần như luôn bị bỏ.

Nửa 1 — nội dung giống hệt. Đăng nhập sai: cùng một mã lỗi, cùng một câu, cùng một HTTP status, dù email có tồn tại hay không. Đăng ký: cùng một 202 Accepted "kiểm email của bạn" cho cả email mới lẫn email đã có — thư gửi đi thì khác nhau, nhưng response thì không. Reset mật khẩu: y hệt.

Nửa 2 — thời gian giống nhau. Đây là nửa quyết định: nếu code trả về sớm khi không tìm thấy user thì hai response giống nhau từng byte vẫn khác nhau 700ms, và 700ms là một câu trả lời rõ ràng.

Cách làm: khi không tìm thấy user, vẫn chạy phép so hash lên một hash giả có cùng tham số. Trong .NET: PasswordHasher.VerifyHashedPassword(dummyUser, DummyHash, password). Không phải Task.Delay — độ trễ cố định vẫn để lại phương sai đo được, và nó làm chậm mọi request thật.

Và cùng lúc, rate limit theo email chứ không chỉ theo IP: liệt kê 10.000 email cần 10.000 request, nên một giới hạn theo email biến việc đó thành không khả thi ngay cả khi có kênh rò.

Lớp 1c

Deploy chỉ artifact đã build — không bao giờ copy cả thư mục làm việc

bắt buộc

Bản vá cho kênh ③, và nguyên nhân của nó luôn là một dòng: COPY . . trong Dockerfile.

Cách đúng là multi-stage build: stage build có source, stage runtime chỉ có output của dotnet publish. Không có .git/, không có .env, không có appsettings.Development.json, không có file test, không có node_modules của dev — vì chúng chưa bao giờ được copy vào image chạy thật.

Cộng ba việc:

  • .dockerignore liệt kê .git, .env*, **/node_modules, **/bin, **/obj, *.md. Nó là lưới thứ hai, không phải lưới đầu.
  • Chặn ở reverse proxy: mọi đường dẫn bắt đầu bằng dấu chấm trả 404 (location ~ /\. { return 404; }). Lưới thứ ba, cho trường hợp một static handler mới xuất hiện.
  • Swagger/Scalar chỉ ở Development. Ở production nó là danh mục đầy đủ mọi endpoint, kèm hình dạng request — kể cả endpoint mà UI không dùng và không ai kiểm quyền (xem topic access-control).
Lớp 2

Bỏ header nhận diện, và tách secret khỏi bundle client

bắt buộc

Kênh ② và ④.

Header — bỏ những gì chỉ nói cho kẻ tấn công biết tra CVE nào:

C#
builder.WebHost.ConfigureKestrel(o => o.AddServerHeader = false);

Cộng X-Powered-ByX-AspNet-Version gỡ ở reverse proxy. Đây là lớp 2 vì nó chỉ làm chậm — phiên bản vẫn suy ra được từ hành vi — nhưng nó rẻ và nó loại bỏ toàn bộ lớp quét tự động.

Bundle client — luật một dòng: mọi thứ trong bundle là công khai. Không có "khoá API chỉ dùng ở frontend" nào là bí mật.

  • Tiền tố NEXT_PUBLIC_/VITE_ là một quyết định, không phải một chi tiết cú pháp: biến không có tiền tố đó không được rò ra client, và có tiền tố đó nghĩa là "tôi chấp nhận nó công khai".
  • Không publish source map ở production, hoặc upload chúng lên Sentry/tương đương rồi xoá khỏi thư mục phục vụ. Source map là toàn bộ source code, có comment.
  • Secret scanning trong CI (gitleaks, trufflehog) chặn merge, và quét cả lịch sử commit — đó là nơi secret đã "xoá" vẫn còn.
Lớp 3

Phát hiện: 404 và 500 là tín hiệu, không phải nhiễu

Khối 2 nói rằng rò rỉ không có triệu chứng. Nên phát hiện phải nhìn vào thứ mà người ta thường lọc ra khỏi dashboard:

  • Tốc độ 404 theo IP. Quét wordlist tìm .git, .env, /backup.sql sinh ra hàng trăm 404 trong vài giây. Đây là tín hiệu độ nhiễu rất thấp — người dùng thật không tạo ra hình dạng đó.
  • Bất kỳ 500 nào là một bug, và có thể là một lần dò. 500 với id=abc ở một endpoint nhận {id:long} nghĩa là ai đó đang thử sai kiểu để lấy stack trace. Alert theo mã lỗi mới xuất hiện, không theo tổng số.
  • Canary token trong file dễ đoán. Đặt một AWS key giả (canarytokens.org) vào /.env.example và một chỗ trong lịch sử git. Nó không có quyền gì, nhưng khi có người dùng nó, bạn biết chính xác là đã có ai đọc được thứ họ không nên đọc — và biết trước khi họ tìm được khoá thật.

Đây là lớp 3 vì không biện pháp nào ở đây chặn gì. Nhưng với một họ lỗi không có triệu chứng, phát hiện là thứ duy nhất cho bạn biết bước một đã xảy ra — trước khi bước hai tới.

07

Kiểm chứng đã vá

1. Test chặn merge cho kênh ①. Test khẳng định response 500 không chứa dấu hiệu của stack trace, đường dẫn tuyệt đối, hay chuỗi kết nối. Xem tab csharp / test. Đây là phép kiểm duy nhất bắt được lỗi "ASPNETCORE_ENVIRONMENT đặt sai ở production" — thứ mà không dòng code nào có lỗi.

2. Test hằng thời gian cho kênh ⑤. Đo nhiều lần và so trung vị giữa email tồn tại và không tồn tại. Trung vị chứ không trung bình: một lần GC pause làm trung bình vô nghĩa. Chênh lệch phải dưới một ngưỡng, và ngưỡng đó nên rộng (2×) để test không nhấp nháy trên CI chậm.

3. Quét đường dẫn lộ ra — chạy trên staging sau mỗi deploy (kênh ③):

Shell
B=https://staging.example.comFAIL=0for p in /.git/HEAD /.git/config /.env /.env.production /appsettings.json \         /appsettings.Production.json /.DS_Store /backup.sql /web.config \         /swagger /scalar /debug /actuator/env /server-status; do  C=$(curl -s -o /dev/null -w '%{http_code}' "$B$p")  [ "$C" = "404" ] || { echo "LỘ RA: $p → $C"; FAIL=1; }doneexit $FAIL

4. Kiểm header (kênh ②):

Shell
H=$(curl -sI $B/ | tr -d '\r')for bad in '^Server:' '^X-Powered-By:' '^X-AspNet' '^X-Runtime'; do  echo "$H" | grep -qiE "$bad" && { echo "header lộ: $bad"; exit 1; }doneexit 0

5. Quét bundle client và secret trong lịch sử git (kênh ④):

Shell
# Source map không được publishfind ./dist ./.next -name '*.map' -print -quit | grep -q . \  && { echo "source map trong artifact build"; exit 1; } # Secret trong bundle, và trong CẢ lịch sử commit — đó là nơi secret "đã xoá" còn nguyêngitleaks detect --no-banner --redact --exit-code 1grep -rEo '(sk_live_|AKIA|ghp_|xox[baprs]-)[A-Za-z0-9]{10,}' ./dist ./.next 2>/dev/null \  && { echo "secret trong bundle"; exit 1; }exit 0

6. Đối chiếu endpoint anonymous với danh sách đã duyệt — cùng phép kiểm ở topic access-control. Một endpoint mới không cần đăng nhập là một kênh rò mới, và nó xuất hiện qua một dòng [AllowAnonymous].

C#Test bắt lỗi cấu hình môi trường — thứ không có dòng code nào chịu trách nhiệm.
public class DisclosureTests : IClassFixture<ProductionLikeFixture>{    private readonly ProductionLikeFixture _fx;   // ASPNETCORE_ENVIRONMENT=Production     public DisclosureTests(ProductionLikeFixture fx) => _fx = fx;     /// <summary>    /// Kênh ① — phép kiểm quan trọng nhất của topic này, vì lỗi mà nó bắt được KHÔNG    /// nằm ở dòng code nào: nó nằm ở một biến môi trường đặt sai. Không có test này    /// thì không có gì đỏ khi ai đó deploy với Development ở production.    /// </summary>    [Theory]    [InlineData("/api/orders/abc")]           // sai kiểu → FormatException    [InlineData("/api/orders/99999999999999999999")]  // tràn Int64    [InlineData("/api/boom")]                 // endpoint cố tình ném lỗi    public async Task Error_responses_never_contain_internals(string path)    {        var res = await _fx.Client.GetAsync(path);        var body = await res.Content.ReadAsStringAsync();         // Dấu hiệu của stack trace và của hạ tầng. Danh sách này là DỮ LIỆU: khi ai        // đó phát hiện một dạng rò mới, họ thêm một dòng ở đây.        foreach (var leak in new[]                 {                     "at SecLab.",          // khung stack                     "/src/",               // đường dẫn tuyệt đối lúc build                     "Exception:",           // tên exception                     ".cs:line",             // số dòng                     "Npgsql",               // tên thư viện hạ tầng                     "Host=", "Password=",   // chuỗi kết nối                     "core.app_user",        // tên bảng                 })            Assert.DoesNotContain(leak, body, StringComparison.OrdinalIgnoreCase);         // Và khẳng định mặt còn lại: có traceId để tra. Một bản vá "trả chuỗi rỗng"        // cũng pass mọi khẳng định ở trên, và nó làm hệ thống không debug được.        Assert.Contains("traceId", body);    }     /// <summary>Kênh ② — header nhận diện. Một dòng cấu hình, và nó bị mất khi đổi host.</summary>    [Fact]    public async Task Response_headers_do_not_advertise_the_stack()    {        var res = await _fx.Client.GetAsync("/");         Assert.False(res.Headers.Contains("Server"));        Assert.False(res.Headers.Contains("X-Powered-By"));        Assert.False(res.Headers.Contains("X-AspNet-Version"));    }     /// <summary>Swagger ở production là danh mục đầy đủ mọi endpoint, kèm hình dạng request.</summary>    [Theory]    [InlineData("/swagger")]    [InlineData("/swagger/v1/swagger.json")]    [InlineData("/scalar")]    public async Task Api_documentation_is_not_served_in_production(string path)    {        Assert.Equal(HttpStatusCode.NotFound, (await _fx.Client.GetAsync(path)).StatusCode);    }     /// <summary>    /// Kênh ⑤ — hằng thời gian.    ///    /// So TRUNG VỊ, không so trung bình: một lần GC pause hay một lần cold start làm    /// trung bình vô nghĩa. Và ngưỡng rộng (2×) để test không nhấp nháy trên CI chậm —    /// một test nhấp nháy sẽ bị ai đó tắt đi, và lúc đó kênh rò mở lại.    /// </summary>    [Fact]    public async Task Login_timing_does_not_reveal_whether_the_account_exists()    {        await _fx.SeedUserAsync("alice@acme.com", "correct-horse-battery");         var existing = await MeasureMedianAsync("alice@acme.com", samples: 15);        var missing = await MeasureMedianAsync("no-such-user@acme.com", samples: 15);         var ratio = Math.Max(existing, missing) / Math.Min(existing, missing);         Assert.True(ratio < 2.0,            $"thời gian lệch {ratio:F1}× (tồn tại {existing:F0}ms, không tồn tại {missing:F0}ms) " +            "— nhánh không tìm thấy user đang trả về sớm");    }     /// <summary>Và nội dung cũng phải giống hệt — nửa còn lại của kênh ⑤.</summary>    [Fact]    public async Task Login_responses_are_byte_identical()    {        await _fx.SeedUserAsync("bob@acme.com", "correct-horse-battery");         var a = await PostLoginAsync("bob@acme.com", "wrong-password");        var b = await PostLoginAsync("no-such-user@acme.com", "wrong-password");         Assert.Equal(a.StatusCode, b.StatusCode);        Assert.Equal(await a.Content.ReadAsStringAsync(), await b.Content.ReadAsStringAsync());    }     /// <summary>Đăng ký không được nói email đã tồn tại — đó là một API liệt kê người dùng.</summary>    [Fact]    public async Task Registration_does_not_reveal_existing_accounts()    {        await _fx.SeedUserAsync("carol@acme.com", "x");         var existing = await PostRegisterAsync("carol@acme.com");        var fresh = await PostRegisterAsync("dave@acme.com");         Assert.Equal(HttpStatusCode.Accepted, existing.StatusCode);        Assert.Equal(HttpStatusCode.Accepted, fresh.StatusCode);        Assert.Equal(await existing.Content.ReadAsStringAsync(),                     await fresh.Content.ReadAsStringAsync());         // Sự khác nhau nằm trong THƯ gửi đi, không trong response.        Assert.Equal("password-reset-hint", _fx.LastEmailTemplateFor("carol@acme.com"));        Assert.Equal("confirm-address", _fx.LastEmailTemplateFor("dave@acme.com"));    }     private async Task<double> MeasureMedianAsync(string email, int samples)    {        var times = new List<double>(samples);        // Một lần chạy nóng để loại cold start khỏi phép đo.        await PostLoginAsync(email, "wrong");         for (var i = 0; i < samples; i++)        {            var sw = Stopwatch.StartNew();            await PostLoginAsync(email, "wrong");            times.Add(sw.Elapsed.TotalMilliseconds);        }         times.Sort();        return times[times.Count / 2];    }}
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
Trả về "Đã có lỗi xảy ra" và không có gì khácĐóng kênh rò và đóng luôn khả năng vận hành. Correlation id là phần làm bản vá này dùng được
Đặt ASPNETCORE_ENVIRONMENT=Production rồi tin là xongKhông dòng code nào có lỗi, nên không test nào đỏ khi ai đó đặt sai ở một môi trường. Phép kiểm 1 ở khối 7 là thứ bắt được
try/catch trong từng controller40 chỗ để làm sai, và chỗ thứ 41 sẽ thiếu. Một middleware là một chỗ
Response giống hệt nhau, nhưng trả về sớm khi không có userNội dung giống, thời gian lệch 700ms. Kênh ⑤ vẫn mở, và nó vẫn đo được
Task.Delay để làm đều thời gianPhương sai vẫn đo được, và mọi request thật cũng chậm theo. Chạy phép so hash giả mới đúng
.dockerignore mà vẫn COPY . .Lưới thứ hai không thay được lưới đầu. Multi-stage build là bản vá
Xoá secret khỏi code rồi commitNó vẫn nằm trong lịch sử git, và lịch sử git đi theo .git/ lộ ra. Phải rotate khoá
Khoá API "chỉ dùng ở frontend"Mọi thứ trong bundle là công khai. Nếu nó cần bí mật thì lời gọi phải đi qua server
Đăng ký trả 409 cho email đã tồn tạiĐó là một API liệt kê người dùng, có tài liệu. Trả 202 cho cả hai và nói khác nhau trong THƯ

Sai lầm về mức độ, và nó là sai lầm chính của topic này: đánh giá từng kênh rò một cách đơn lẻ rồi kết luận "rủi ro thấp". Giá trị của rò rỉ là cộng dồn: phiên bản framework cộng đường dẫn tuyệt đối cộng danh sách email có tài khoản không phải ba lỗi thấp — nó là một cuộc tấn công đã được nhắm sẵn.

Sai lầm về phạm vi: chỉ nhìn response của API. Kênh ③ và ④ nằm ở tầng build và deploy, không ở trong code ứng dụng — nên chúng không xuất hiện trong bất kỳ lần code review nào.

09

Nguồn tham khảo

Bậc 1RFC 9457 — Problem Details for HTTP APIs · IETF · RFC · RFC 9457
Bậc 1Handle errors in ASP.NET Core · Microsoft · ASP.NET Core docs · .NET 8
Bậc 2Information disclosure · PortSwigger · Web Security Academy
Bậc 2Error Handling Cheat Sheet · OWASP · Cheat Sheet Series
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để bình luận

Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.

Đăng kýĐăng nhập

Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.

Đang tải bình luận…