Một nghiên cứu thất bại

Người tìm lổ hổng phần mềm chuyên nghiệp như tôi ít ai nói về các nghiên cứu thất bại. Tôi cũng chưa từng nói, mặc dù thất bại nhiều lắm, không phải vì xấu hổ, chỉ vì lười, kể một câu chuyện dài mà rốt cuộc chẳng có kết cục đẹp thì kể làm gì. Nhưng vừa rồi tôi mới làm một nghiên cứu, thất bại ê chề, tốn tiền tốn của mà chẳng thu được gì, nên tôi muốn gỡ vốn bằng cách viết lại, để mai mốt bạn nào có làm biết đường mà tránh.

Không dông dài nữa, câu chuyện là thế này. Có người nhờ tôi coi đoạn chương trình Java này: https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/internal/tls/OkHostnameVerifier.java. Đoạn code này có nhiệm vụ rất quan trọng: kiểm tra tên miền của các certificate (chứng chỉ) SSL. Chứng chỉ SSL giống như passport, trên đó có tên miền của máy chủ, kèm theo chữ ký điện tử của một cơ quan có thẩm quyền, được gọi là Certificate Authority. Khi kết nối đến một máy chủ nào đó, để đảm bảo an toàn bạn phải kiểm tra xem tên miền trên chứng chỉ của máy chủ có trùng với tên miền mà bạn đang muốn kết nối hay không. Đây chính là chức năng của đoạn code ở trên.

Tôi tìm thấy một vấn đề ở hàm verifyHostname ở dòng thứ 73:

  /** Returns true if {@code certificate} matches {@code hostname}. */
  private boolean verifyHostname(String hostname, X509Certificate certificate) {
    hostname = hostname.toLowerCase(Locale.US);
    boolean hasDns = false;
    List<String> altNames = getSubjectAltNames(certificate, ALT_DNS_NAME);
    for (int i = 0, size = altNames.size(); i < size; i++) {
      hasDns = true;
      if (verifyHostname(hostname, altNames.get(i))) {
        return true;
      }
    }

    if (!hasDns) {
      X500Principal principal = certificate.getSubjectX500Principal();
      // RFC 2818 advises using the most specific name for matching.
      String cn = new DistinguishedNameParser(principal).findMostSpecific("cn");
      if (cn != null) {
        return verifyHostname(hostname, cn);
      }
    }

    return false;
  }

Hàm này nhận vào một certificate và tên miền, trả về true nếu tên miền trùng với một trong những tên miền của chứng chỉ; ngược lại trả về false. Quá trình kiểm tra gồm hai giai đoạn:

1/ Kiểm tra xem tên miền nhập vào có trùng với tên miền nào trong mục SubjectAltName của chứng chỉ. Nếu có trả về true; nếu không thực hiện bước 2.

2/ Kiểm tra xem tên miền nhập vào có trùng với phần Common Name trong mục Distinguished Name của chứng chỉ. Nếu có trả về true; nếu không trả về false.

Tôi chú ý đến phần so sánh Common Name và mở code của DistinguishedNameParser ra đọc: https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/internal/tls/DistinguishedNameParser.java.

Distinguished Name có thể có giá trị như sau:

C=US, ST=California, L=Mountain View, O=Google Inc, CN=www.google.com

DistinguishedNameParser khá phức tạp, nhưng tóm tắt thì nhiệm vụ của hàm findMostSpecific là đi tìm chuỗi CN= trong mục Distinguished Name và trả về www.google.com. Lúc coi đến đây, tôi tự hỏi: chuyện gì xảy ra nếu trong Distinguished Name có đến hai Common Name? Ví dụ như:

C=US, ST=California, L=Mountain View, O=Google Inc, CN=www.google.com, CN=www.evil.com

Lúc này DistinguishedNameParser sẽ trả về Common Name thứ nhất, tức là www.google.com, và mặc kệ Common Name thứ hai. Nhưng liệu có thư viện nào trả về kết quả thứ hai hay không? Khi đó chúng ta sẽ có một hướng tấn công mới, giống như HTTP Paramater Pollution.

Có rất nhiều thư viện chịu trách nhiệm kiểm tra Common Name trong certificate. Tôi coi qua một vòng, thấy có những thư viện chấp nhận Common Name cuối cùng, nhưng cũng có thư việc chấp nhận tất cả Common Name. Vài ví dụ:
- Java chấp nhận Common Name đầu tiên.
- GoLang chấp nhận Common Name cuối cùng.
- BoringSSL chấp nhận tất cả.

Trong đầu tôi lúc đó hình dung ra một các tấn công như sau:
1/ Tạo một Certificate Signing Request (CSR) có chứa hai Common Name: www.tetcon.org và www.google.com theo thứ tự đó.
2/ Đi tìm một Certificate Authority ký CSR của tôi, với hi vọng rằng họ sẽ giữ nguyên Common Name, nhưng chỉ kiểm tra sở hữu tên miền thứ nhất. Do đó tôi sẽ có được một certificate có hai Common Name.
3/ Sử dụng certificate tạo ra ở bước 2/ để đánh lừa GoLang và BoringSSL. Nếu đánh lừa được BoringSSL tức là đánh lừa được Chrome.

Nói cách khác tôi yêu cầu Certificate Authority cấp một certificate cho tên miền www.tetcon.org, thuộc sở hữu của tôi, nhưng tôi có thể sử dụng certificate đó cho www.google.com.

Tối hôm đó về nhà tôi tìm cách tạo ra một CSR có hai Common Name. Certificate và CSR được định dạng theo chuẩn ASN.1. Thông thường người ta tạo ra CSR bằng cách sử dụng lệnh openssl req, nhưng tôi cho rằng lệnh này không cho phép tạo CSR có hai Common Name. Hoặc là tôi phải sửa lại mã nguồn của lệnh openssl req, hoặc là tôi phải tìm cách tạo ra CSR từ đầu. Tôi chọn phương án thứ hai, do trước đây tôi đã có sử dụng pyasn1 làm một việc tương tự. Tôi mất hơn bốn tiếng đồng hồ mới viết xong một script để tạo CSR như ý.

Bài học #1: hiểu công cụ của mình. Có một cách rất đơn giản để tạo ra CSR có hai Common Name. openssl req chấp nhận tham số "-subj". Tôi đã mất thời gian một cách vô ích vì không biết xài công cụ có sẵn.

Bạn nào muốn thử có thể chạy các lệnh sau đây để tạo ra một CSR có hai Common Name:

        openssl req -newkey rsa:2048 -sha256 -out multivalue_subject_cn.csr \
        -keyout /dev/null -nodes -subj "/C=US/ST=California/L=Mountain View/O=Google \
        Inc/CN=www.tetcon.orr/CN=www.google.com"
     
        openssl req -in multivalue_subject_cn.csr -verify -text
     
Dẫu sao thì tôi cũng tạo được một CSR như ý muốn. Bước tiếp theo là đi tìm một CA chịu ký CSR và giữ nguyên Distinguished Name. Tôi mất hai ngày, vài trăm đôla, nhưng chẳng thu được gì cả. Các CA, có vẻ rút kinh nghiệm từ các tấn công trước, đều tự tạo lại Distinguished Name, không chấp nhận giá trị nằm trong CSR của tôi. Nhưng cho đến lúc đó tôi vẫn tin là trong vô vàn CA thể nào cũng có một CA giữ nguyên Distinguished Name.

Vấn đề là dẫu tôi có nhận được một certificate với hai Common Name, tôi không thể dùng nó ở đâu cả. Nếu bạn quay lại lên trên để xem lại hàm verifyHostname sẽ thấy nó chỉ kiểm tra Common Name khi certificate không có mục SubjectAltName. Khi CA tạo ra một certificate mới, lúc nào họ cũng chép Common Name vào mục SubjectAltName. Vẫn có khả năng CA chép cả hai Common Name vào, nhưng tôi chưa tìm thấy cái nào như vậy và khả năng cao là không có.

Sai lầm ở đây là tôi đã tập trung hoàn toàn vào chuyện đi tìm cách khai thác lổ hổng, nhưng lại không nghĩ đến những tình huống có thể khiến lổ hổng của tôi bị vô hiệu hóa. Tôi nhìn vào đoạn mã của hàm verifyHostname nhưng chỉ tập trung vào lổ hổng, bỏ qua điều kiện để lổ hổng có thể được khai thác. Ở đây, tôi bị mắc vào confirmation bias, chỉ muốn đi tìm bằng chứng ủng hộ cho giả thuyết của mình, một sai lầm nguy hiểm khi làm nghiên cứu và cả trong cuộc sống hàng ngày.

Bài học #2: phải thử sai (falsify) ý tưởng của mình. Phải thử tất cả những tình huống ý tưởng không chạy được. Như một giáo sư từng nào, thất bại trong nghiên cứu là bình thường, nhưng phải thất bại càng nhanh càng tốt! Đừng bỏ ra cả đống thời gian và tiền bạc rồi mới phát hiện ra nghiên cứu của mình bất khả thi.

Có một cách đơn giản và miễn phí để thử sai ý tưởng tấn công của tôi. Tôi có thể tự tạo một CA, cấu hình Chrome để nó tin tưởng CA, rồi dùng CA ký một certificate có hai Common Name, có hay không có SubjectAltName. Ngay lập tức tôi sẽ biết liền Chrome sẽ ứng xử ra sao, mà không phải tốn tiền hay thời gian.

Sai lầm lớn nhất của tôi là không tìm và đọc các kết quả nghiên cứu trước đó (ví dụ như http://www.ioactive.com/pdfs/PKILayerCake.pdf hoặc http://wiki.cacert.org/VhostTaskForce#Interoperability_Test). Khi phát hiện ra hướng tấn công này, tôi đã nghĩ chắc nó không mới đâu. Tôi biết chưa ai tìm thấy bất kỳ lỗi nào thuộc dạng tấn công này, nhưng thay vì kết luận người ta thử rồi nhưng không khai thác được, tôi lại kết luận (hơi ngông) chắc chưa ai tìm được lỗi nào hết. Nếu tôi chịu khó tìm khoảng 5'-10', chắc chắn tôi đã hiểu tại sao không có lỗi nào được phát hiện, trước khi vùi đầu vùi cổ đi tìm.

Bài học #3: hiểu nghiên cứu của mình. Trước khi bắt tay vào triển khai bất kỳ ý tưởng nào, hãy thử tìm xem có ai nói về ý tưởng này chưa, và nếu có, người ta nói cái gì. Tìm chừng vài phút trên Google có khi sẽ tiết kiệm được khối thời gian và tiền bạc đầu tư vào một ý tưởng chẳng dẫn đến đâu.

--

Nói chung là thất bại ê chề, nhưng cũng vui. Tiền thì công ty trả, nhưng tôi đang tìm cách đi đòi refund, không biết bọn CA có trả lại hay không nữa @_@.

Comments

Unknown said…
This comment has been removed by the author.
lotusirous said…
Chào anh Thái. Trong dự án đó thì anh nghiên cứu trong bao lâu sẽ biết thất bại ? Rồi sau khi thất bại anh sẽ làm gì với dự án đó ?
Tiền công ty đã trả lại còn đi đòi refund thì anh Thái khôn à nha :).
Lê Tùng said…
Cho mình share nhé. Thanks