Ngày nay hiếm có người nào chưa từng bấm vào nút "Khôi phục mật khẩu" đó trong sự thất vọng sâu sắc. Ngay cả khi có vẻ như mật khẩu đó chắc chắn là chính xác, thì bước tiếp theo của việc khôi phục nó hầu như diễn ra suôn sẻ bằng cách truy cập một liên kết từ email và nhập mật khẩu mới (đừng lừa ai cả; nó hầu như không mới vì bạn vừa nhập nó ba lần ở bước 1 trước khi nhấn nút đáng ghét).
Tuy nhiên, logic đằng sau các liên kết email là điều cần được xem xét kỹ lưỡng vì việc để thế hệ của nó không an toàn sẽ mở ra vô số lỗ hổng liên quan đến việc truy cập trái phép vào tài khoản người dùng. Thật không may, đây là một ví dụ về cấu trúc URL khôi phục dựa trên UUID mà nhiều người có thể gặp phải, tuy nhiên cấu trúc này không tuân theo các nguyên tắc bảo mật:
https://.../recover/d17ff6da-f5bf-11ee-9ce2-35a784c01695
Nếu một liên kết như vậy được sử dụng, điều đó thường có nghĩa là bất kỳ ai cũng có thể lấy được mật khẩu của bạn và nó chỉ đơn giản như vậy. Bài viết này nhằm mục đích đi sâu vào các phương pháp tạo UUID và chọn các phương pháp tiếp cận không an toàn cho ứng dụng của chúng.
UUID là nhãn 128 bit thường được sử dụng để tạo mã định danh giả ngẫu nhiên với hai thuộc tính có giá trị: nó đủ phức tạp và đủ độc đáo. Hầu hết, đó là những yêu cầu chính để ID rời khỏi phần phụ trợ và được hiển thị cho người dùng một cách rõ ràng ở giao diện người dùng hoặc thường được gửi qua API với khả năng được quan sát. Nó khiến người ta khó đoán hoặc mạnh mẽ so với id = 123 (độ phức tạp) và ngăn ngừa xung đột khi id được tạo trùng lặp với id được sử dụng trước đó, ví dụ: một số ngẫu nhiên từ 0 đến 1000 (tính duy nhất).
Trước hết, các phần "đủ" thực sự đến từ một số phiên bản của Mã định danh duy nhất toàn cầu, để ngỏ cho các khả năng sao chép nhỏ, tuy nhiên, điều này dễ dàng được giảm thiểu bằng logic so sánh bổ sung và không gây ra mối đe dọa do các điều kiện khó được kiểm soát đối với sự xuất hiện của nó. Và thứ hai, mức độ phức tạp của các phiên bản UUID khác nhau được mô tả trong bài viết, nhìn chung nó được cho là khá tốt ngoại trừ các trường hợp góc xa hơn.
Các khóa chính trong các bảng cơ sở dữ liệu dường như dựa trên cùng một nguyên tắc phức tạp và độc đáo như UUID. Với việc áp dụng rộng rãi các phương pháp tích hợp để tạo ra nó trong nhiều ngôn ngữ lập trình và hệ thống quản lý cơ sở dữ liệu, UUID thường là lựa chọn đầu tiên để xác định các mục dữ liệu được lưu trữ và là trường để nối các bảng nói chung và các bảng con được phân chia bằng cách chuẩn hóa. Gửi ID người dùng đến từ cơ sở dữ liệu qua API để phản hồi một số hành động nhất định cũng là cách làm phổ biến để làm cho quá trình hợp nhất các luồng dữ liệu trở nên đơn giản hơn mà không cần tạo thêm ID tạm thời và liên kết chúng với các ID trong bộ lưu trữ dữ liệu sản xuất.
Xét về các ví dụ đặt lại mật khẩu, kiến trúc có nhiều khả năng bao gồm một bảng chịu trách nhiệm cho thao tác chèn các hàng dữ liệu có UUID được tạo mỗi khi người dùng nhấp vào nút. Nó bắt đầu quá trình khôi phục bằng cách gửi email đến địa chỉ được liên kết với người dùng theo user_id của họ và kiểm tra xem người dùng nào sẽ đặt lại mật khẩu dựa trên mã nhận dạng mà họ có khi liên kết đặt lại được mở. Tuy nhiên, có những nguyên tắc bảo mật cho những số nhận dạng như vậy hiển thị cho người dùng và việc triển khai UUID nhất định đáp ứng chúng với mức độ thành công khác nhau.
Phiên bản 1 của thế hệ UUID chia 128 bit của nó thành địa chỉ MAC 48 bit của mã định danh tạo thiết bị, dấu thời gian 60 bit, 14 bit được lưu trữ để tăng giá trị và 6 để lập phiên bản. Do đó, việc đảm bảo tính duy nhất được chuyển từ các quy tắc trong logic mã sang các nhà sản xuất phần cứng, những người có nhiệm vụ gán giá trị cho mọi máy mới trong sản xuất một cách chính xác. Chỉ để lại 60+14 bit để thể hiện tải trọng có thể thay đổi hữu ích sẽ làm giảm tính toàn vẹn của mã định danh, đặc biệt với logic minh bạch đằng sau nó. Chúng ta hãy xem một chuỗi số lượng UUID v1 được tạo ra sau đó:
from uuid import uuid1 for _ in range(8): print(uuid1())
d17ff6da-f5bf-11ee-9ce2-35a784c01695 d17ff6db-f5bf-11ee-9ce2-35a784c01695 d17ff6dc-f5bf-11ee-9ce2-35a784c01695 d17ff6dd-f5bf-11ee-9ce2-35a784c01695 d17ff6de-f5bf-11ee-9ce2-35a784c01695 d17ff6df-f5bf-11ee-9ce2-35a784c01695 d17ff6e0-f5bf-11ee-9ce2-35a784c01695 d17ff6e1-f5bf-11ee-9ce2-35a784c01695
Có thể thấy, phần "-f5bf-11ee-9ce2-35a784c01695" luôn được giữ nguyên. Phần có thể thay đổi chỉ đơn giản là biểu diễn thập lục phân 16 bit của chuỗi 3514824410 - 3514824417. Đây chỉ là một ví dụ hời hợt vì các giá trị sản xuất thường được tạo ra với nhiều khoảng trống đáng kể hơn về thời gian ở giữa, do đó, phần liên quan đến dấu thời gian cũng bị thay đổi. Phần dấu thời gian 60 bit cũng có nghĩa là phần quan trọng hơn của mã định danh được thay đổi trực quan trên một mẫu ID lớn hơn. Điểm cốt lõi vẫn giữ nguyên: UUIDv1 có thể dễ dàng đoán được, tuy nhiên ban đầu nó trông có vẻ ngẫu nhiên.
Chỉ lấy giá trị đầu tiên và cuối cùng từ danh sách 8 id đã cho. Do số nhận dạng được tạo nghiêm ngặt, do đó, rõ ràng chỉ có 6 ID được tạo giữa hai ID đã cho (bằng cách trừ đi các phần có thể thay đổi theo hệ thập lục phân) và giá trị của chúng cũng có thể được tìm thấy một cách rõ ràng. Phép ngoại suy logic như vậy là phần cơ bản đằng sau cái gọi là cuộc tấn công Sandwich nhằm mục đích ép buộc UUID biết hai giá trị biên này. Luồng tấn công rất đơn giản: người dùng tạo UUID A trước khi tạo UUID mục tiêu và UUID B ngay sau đó. Giả sử cùng một thiết bị có phần MAC 48 bit tĩnh chịu trách nhiệm cho cả ba thế hệ, nó sẽ đặt cho người dùng một chuỗi ID tiềm năng giữa A và B, nơi đặt UUID mục tiêu. Tùy thuộc vào khoảng cách thời gian giữa các ID được tạo với mục tiêu, phạm vi có thể có số lượng lớn có thể truy cập được bằng phương pháp bạo lực: kiểm tra mọi UUID có thể để tìm những ID hiện có trong số các ID trống.
Trong các yêu cầu API có điểm cuối khôi phục mật khẩu được mô tả trước đây, nó có nghĩa là gửi hàng trăm hoặc hàng nghìn yêu cầu kèm theo UUID cho đến khi tìm thấy phản hồi nêu rõ URL hiện có. Với việc đặt lại mật khẩu, nó dẫn đến một thiết lập trong đó người dùng có thể tạo liên kết khôi phục trên hai tài khoản mà họ kiểm soát chặt chẽ nhất có thể để nhấn nút khôi phục trên tài khoản mục tiêu mà họ không có quyền truy cập mà chỉ biết email/đăng nhập. Sau đó, thư gửi đến các tài khoản được kiểm soát có UUID khôi phục A và B sẽ được biết và liên kết mục tiêu để khôi phục mật khẩu cho tài khoản mục tiêu có thể bị ép buộc mà không có quyền truy cập vào email đặt lại thực tế.
Lỗ hổng bắt nguồn từ khái niệm chỉ dựa vào UUIDv1 để xác thực người dùng. Bằng cách gửi liên kết khôi phục cấp quyền truy cập để đặt lại mật khẩu, người ta giả định rằng bằng cách nhấp vào liên kết, người dùng được xác thực là người được cho là sẽ nhận liên kết. Đây là phần mà quy tắc xác thực không thành công do UUIDv1 tiếp xúc với lực lượng vũ phu đơn giản giống như thể cửa của ai đó có thể được mở bằng cách biết chìa khóa của cả hai cửa hàng xóm của họ trông như thế nào.
Phiên bản đầu tiên của UUID chủ yếu được coi là cũ một phần vì logic thế hệ chỉ sử dụng một phần nhỏ hơn kích thước mã định danh làm giá trị ngẫu nhiên. Các phiên bản khác, như v4, cố gắng giải quyết vấn đề này bằng cách giữ càng ít không gian càng tốt cho việc lập phiên bản và để lại tối đa 122 bit làm tải trọng ngẫu nhiên. Nói chung, nó mang đến tổng số các biến thể có thể có cho một con số khổng lồ 2^122
, hiện được coi là đáp ứng phần "đủ" liên quan đến yêu cầu về tính duy nhất của mã định danh và do đó đáp ứng các tiêu chuẩn bảo mật. Lỗ hổng brute-force có thể xuất hiện nếu việc triển khai thế hệ bằng cách nào đó làm giảm đáng kể số bit còn lại cho phần ngẫu nhiên. Nhưng không có công cụ sản xuất hoặc thư viện thì có nên như vậy không?
Chúng ta hãy cùng tìm hiểu về mật mã một chút và xem xét kỹ cách triển khai tạo UUID phổ biến của JavaScript. Đây là hàm randomUUID()
dựa vào mô-đun math.random
để tạo số giả ngẫu nhiên:
Math.floor(Math.random()*0x10);
Và bản thân hàm ngẫu nhiên, tóm lại nó chỉ là phần được quan tâm của chủ đề trong bài viết này:
hi = 36969 * (hi & 0xFFFF) + (hi >> 16); lo = 18273 * (lo & 0xFFFF) + (lo >> 16); return ((hi << 16) + (lo & 0xFFFF)) / Math.pow(2, 32);
Việc tạo giả ngẫu nhiên yêu cầu giá trị hạt giống làm cơ sở để thực hiện các phép toán trên đó nhằm tạo ra các chuỗi số đủ ngẫu nhiên. Các chức năng như vậy chỉ dựa trên nó, có nghĩa là nếu chúng được khởi tạo lại bằng cùng một hạt giống như trước, thì chuỗi đầu ra sẽ khớp. Giá trị gốc trong hàm JavaScript được đề cập bao gồm các biến hi và lo, mỗi biến là số nguyên không dấu 32 bit (0 đến 4294967295 thập phân). Sự kết hợp của cả hai là cần thiết cho mục đích mã hóa, khiến cho gần như không thể đảo ngược hoàn toàn hai giá trị ban đầu bằng cách biết bội số của chúng, vì nó phụ thuộc vào độ phức tạp của hệ số nguyên với số lượng lớn.
Hai số nguyên 32 bit cùng nhau mang lại 2^64
trường hợp có thể đoán các biến hi và lo đằng sau hàm khởi tạo tạo ra UUID. Nếu các giá trị hi và lo được biết bằng cách nào đó, thì không cần nỗ lực để nhân đôi hàm tạo và biết tất cả các giá trị mà nó tạo ra và sẽ tạo ra trong tương lai do tiếp xúc với giá trị hạt giống. Tuy nhiên, 64 bit trong tiêu chuẩn bảo mật có thể được coi là không thể chấp nhận được đối với hành vi bạo lực trong một khoảng thời gian có thể đo lường được để nó có ý nghĩa. Như mọi khi, vấn đề xuất phát từ việc thực hiện cụ thể. Math.random()
lấy 16 bit khác nhau từ mỗi hi và lo thành kết quả 32 bit; tuy nhiên, randomUUID()
trên hết nó lại thay đổi giá trị một lần nữa do thao tác .floor()
và phần có ý nghĩa duy nhất đột nhiên chỉ đến từ hi. Nó không ảnh hưởng đến việc tạo theo bất kỳ cách nào nhưng làm cho các phương pháp mã hóa bị hỏng vì nó chỉ để lại 2^32
kết hợp có thể có cho toàn bộ hạt giống của hàm tạo (không cần phải ép buộc cả hi và lo vì lo có thể được đặt thành bất kỳ giá trị và không ảnh hưởng đến đầu ra).
Luồng bạo lực bao gồm việc thu thập một ID duy nhất và kiểm tra các giá trị cao có thể đã tạo ra nó. Với một số tối ưu hóa và phần cứng máy tính xách tay trung bình, quá trình này có thể chỉ mất vài phút và không yêu cầu gửi nhiều yêu cầu đến máy chủ như trong cuộc tấn công Sandwich mà thực hiện tất cả các hoạt động ngoại tuyến. Kết quả của cách tiếp cận này gây ra sự sao chép trạng thái hàm tạo được sử dụng trong phần phụ trợ để nhận tất cả các liên kết đặt lại đã tạo và trong tương lai trong ví dụ khôi phục mật khẩu. Các bước để ngăn chặn lỗ hổng xuất hiện rất đơn giản và yêu cầu sử dụng các chức năng bảo mật bằng mật mã, ví dụ: crypto.randomUUID()
.
UUID là một khái niệm tuyệt vời và giúp cuộc sống của các kỹ sư dữ liệu trở nên dễ dàng hơn rất nhiều trong nhiều lĩnh vực ứng dụng. Tuy nhiên, nó không bao giờ nên được sử dụng liên quan đến xác thực, vì trong bài viết này, các sai sót trong một số trường hợp nhất định về kỹ thuật tạo ra nó đã được đưa ra ánh sáng. Rõ ràng nó không có nghĩa là tất cả UUID đều không an toàn. Tuy nhiên, cách tiếp cận cơ bản là thuyết phục mọi người không sử dụng chúng vì mục đích bảo mật, điều này hiệu quả hơn và an toàn hơn việc đặt ra các giới hạn phức tạp trong tài liệu về việc sử dụng hoặc cách không tạo ra chúng cho mục đích đó.