Hai ngày trước, tôi đã gửi một pull request liên quan đến bảo mật UUID cho Python và được chấp nhận hợp nhất. Điều này đánh dấu việc sửa thành công một lỗi nhỏ trong phiên bản Python 3.13+. UUID hiện đã phát triển đến thế hệ thứ 8. Một điểm đặc biệt là các thế hệ UUID không phải là sự cải tiến tuần tự của phiên bản trước (trừ UUIDv5 và v6), mà là các thuật toán sinh khác nhau được thiết kế cho các kịch bản sử dụng cụ thể. Do đó, việc lựa chọn phiên bản UUID không dựa trên số phiên bản cao hơn, mà phải xem xét theo ngữ cảnh sử dụng. Tương ứng, mỗi phiên bản UUID có những nguy cơ bảo mật và phương pháp tấn công riêng.
Trong Python, UUID là một thư viện chuẩn. Đến phiên bản mới nhất 3.14+, nó hỗ trợ tất cả các giao diện từ UUIDv1 đến v8 (trừ v2). Bài viết này sẽ tập trung kiểm tra logic sinh các phiên bản UUID khác nhau trong Python cùng với các rủi ro bảo mật tương ứng.
Kiến thức nền tảng
Cơ bản về UUID
Khái niệm UUID (Universally Unique Identifier) và các phiên bản từ 1 đến 4 được giới thiệu lần đầu trong RFC 4122. Các phiên bản UUIDv6, v7, v8 sau đó được đề xuất trong RFC 9562. Trong số các phiên bản, UUIDv4 nổi tiếng vì thường được dùng làm token, mã xác thực hoặc thậm chí là nội dung flag trong các cuộc thi CTF, còn UUIDv7 được sử dụng rộng rãi trong các hệ thống cơ sở dữ liệu hiện đại.
Tất cả các phiên bản UUID đều tuân theo mẫu chung:
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
UUID là giá trị 128 bit, trong đó vị trí M biểu thị phiên bản (Version Field), vị trí N biểu thị biến thể (Variant Field). Tham khảo chi tiết tại: https://www.rfc-editor.org/rfc/rfc9562.html#name-variant-field. Các vị trí còn lại được xác định bởi quy tắc sinh của từng phiên bản.
Theo tiêu chuẩn RFC, chúng ta đặt ra các yêu cầu bảo mật cho UUID:
Chúng tôi cho rằng UUID không nên được sử dụng như dữ liệu nhạy cảm, do đó không cần đảm bảo tính bảo mật của UUID. Tuy nhiên, đối với một số phiên bản cụ thể (như UUIDv4), thiết kế đã có độ an toàn đủ cao để có thể sử dụng cho lưu trữ nội dung nhạy cảm. Đối với UUID lưu trữ thông tin nhạy cảm, RFC đưa ra các yêu cầu sau:
Chúng ta cần đảm bảo hai thuộc tính: thứ nhất là không thể đoán ("unguessable"), thứ hai là không thể va chạm ("unique"). Trong bài viết này, đối với các phiên bản UUID khác nhau, nếu chúng ta có thể phá vỡ một trong hai thuộc tính này, thì có thể nói thuật toán đó không an toàn tại điểm này. Đây là định nghĩa cơ bản về bảo mật UUID trong bài viết. Tất cả điều này đều dựa trên giả định rằng ai đó đang sử dụng UUID để lưu trữ dữ liệu nhạy cảm. Nếu trong thực tế UUID không được dùng cho mục đích nhạy cảm, thì vấn đề bảo mật không còn nằm trong phạm vi thảo luận.
PRNG và CSPRNG
Nói chung, chúng ta phân loại các thuật toán tạo số ngẫu nhiên thành hai loại: Cryptographically-Secure Pseudo-Random Number Generator (CSPRNG) và Pseudo-Random Number Generator (không an toàn về mặt mật mã học). Ví dụ điển hình cho CSPRNG là nhiễu môi trường (`os.urandom()`), trong khi MT19937 (`random.getrandbits()`) là ví dụ cho PRNG.
PRNG có hiệu suất cao hơn CSPRNG, nhưng một khi bị rò rỉ hơn 624*32 byte liên tiếp, có thể sử dụng giải ma trận để khôi phục bộ sinh MT19937, từ đó dự đoán số ngẫu nhiên tiếp theo. Đây cũng là nguồn gốc của phần lớn các phương pháp tấn công được trình bày trong bài viết.
Sau khi làm rõ các khái niệm trên, chúng ta sẽ lần lượt kiểm tra logic sinh các phiên bản UUID khác nhau trong thư viện chuẩn của Python.
UUID trong Python
Đối tượng UUID
Trước hết, các hàm sinh UUID ở các phiên bản khác nhau trong Python cuối cùng đều trả về một đối tượng UUID chứ không phải chuỗi. Đối tượng này có dạng như sau:
UUID(fields=(field1, field2, field3, field4, field5, field6), version=XXX)
Sau khi đối tượng được khởi tạo, Python sẽ tổng hợp các giá trị field và tạo ra chuỗi UUID. Quá trình tổng hợp có thể hiểu là chuẩn hóa, tức là điền các giá trị field và thông tin phiên bản vào chuỗi UUID 128 bit để tạo thành UUID hợp lệ. Trong quá trình tổng hợp có vài điểm quan trọng cần lưu ý:
Chúng ta quan tâm đến dòng 216-220 của đối tượng UUID. Có thể thấy Python sẽ sử dụng phép toán bitwise để ép buộc ghi đè giá trị phiên bản và biến thể vào vị trí M và N. Điều này có nghĩa là trong các field tương ứng (field3 và field4), 2 bit cao nhất là vô nghĩa (sẽ bị ghi đè) nên bạn thường thấy độ dài bit của field là 14 hoặc 62 (thiếu 2 bit để chia hết cho 8) để dành chỗ cho hai vị trí này.
UUIDv1
UUIDv1 là phiên bản đầu tiên của UUID, dựa trên thời gian (time-based). Hãy xem mã nguồn:
def generate_uuid1(node_identifier=None, sequence_number=None):
"""Tạo UUID từ ID máy chủ, số thứ tự và thời gian hiện tại."""
if _time_generator_secure is not None and node_identifier is sequence_number is None:
uuid_time, secure_flag = _time_generator_secure()
try:
safety_status = SecureUUID(secure_flag)
except ValueError:
safety_status = SecureUUID.unknown
return UUID(bytes=uuid_time, is_safe=safety_status)
global _previous_timestamp
import time
nano_seconds = time.time_ns()
# 0x01b21dd213814000 là số khoảng thời gian 100ns giữa
# UUID epoch 1582-10-15 00:00:00 và Unix epoch 1970-01-01 00:00:00.
timestamp_value = nano_seconds // 100 + 0x01b21dd213814000
if _previous_timestamp is not None and timestamp_value <= _previous_timestamp:
timestamp_value = _previous_timestamp + 1
_previous_timestamp = timestamp_value
if sequence_number is None:
import random
sequence_number = random.getrandbits(14) # thay thế cho bộ nhớ ổn định
low_time = timestamp_value & 0xffffffff
mid_time = (timestamp_value >> 32) & 0xffff
high_time_version = (timestamp_value >> 48) & 0x0fff
low_sequence = sequence_number & 0xff
high_sequence_variant = (sequence_number >> 8) & 0x3f
if node_identifier is None:
node_identifier = retrieve_node_id()
return UUID(fields=(low_time, mid_time, high_time_version,
high_sequence_variant, low_sequence, node_identifier), version=1)
Chúng ta nhận thấy: trong các field của UUIDv1, low_time, mid_time, high_time_version về cơ bản là timestamp + version, node là địa chỉ MAC, high_sequence_variant và low_sequence là giá trị 14 bit được sinh ngẫu nhiên (dành 2 bit cho giá trị biến thể).
Logic sinh node nằm trong `retrieve_node_id()`, hãy xem:
def retrieve_node_id():
"""Lấy địa chỉ phần cứng dưới dạng số nguyên 48-bit dương."""
global _current_node
if _current_node is not None:
return _current_node
for fetcher in _FETCHERS + [_secure_node_fetcher]:
try:
_current_node = fetcher()
except:
continue
if (_current_node is not None) and (0 <= _current_node < (1 << 48)):
return _current_node
assert False, '_secure_node_fetcher() returned invalid value: {}'.format(_current_node)
Theo chú thích, khi lấy địa chỉ MAC thất bại, chúng ta sẽ sinh ngẫu nhiên số node 48 bit. Vì đôi khi có các tình huống không muốn tiết lộ địa chỉ MAC, Python sẽ gọi hàm `_secure_node_fetcher()` để sinh ngẫu nhiên node 48 bit. Mã như sau:
def _secure_node_fetcher():
"""Lấy ID node ngẫu nhiên."""
import random
return random.getrandbits(48) | (1 << 40)
Tuy nhiên trong RFC 9562, chúng ta có thể thấy:
Điều đó có nghĩa là: chúng ta nên sử dụng CSPRNG để sinh node. Trong phiên bản hiện tại của Python, chúng ta đang sử dụng PRNG. Điều này đã được tôi sửa đổi trong PR thành CSPRNG. Field node được sinh một lần rồi lưu vào cache, do đó nâng cấp lên CSPRNG ảnh hưởng không đáng kể đến hiệu suất.
Khi thu thập UUIDv1 đầu tiên, địa chỉ MAC đã được biết. Vì địa chỉ MAC không thay đổi, nên các UUID sau sẽ có cùng field node. Điều này có thể dự đoán được. Nghĩa là, những gì không thể dự đoán chỉ còn lại hai field `sequence_number` và field thời gian.
Logic sinh `sequence_number` như sau:
if sequence_number is None:
import random
sequence_number = random.getrandbits(14) # thay thế cho bộ nhớ ổn định
Nhưng đối với tham số `sequence_number` được sinh ngẫu nhiên. Tôi đã kiểm tra logic sinh trong các ngôn ngữ khác, phát hiện đa số đều dùng CSPRNG, duy chỉ có Python vì lý do hiệu suất (theo benchmark của phiên bản mới nhất, CSPRNG chậm hơn PRNG tới 25 lần) vẫn dùng PRNG. Mỗi UUIDv1 như vậy sẽ rò rỉ 14 byte, theo phương pháp tấn công MT19937, chúng ta cần `624*32//(14)+1=1427` lần rò rỉ liên tiếp để dự đoán `sequence_number` tiếp theo.
Để trích xuất 14 bit byte được sinh ngẫu nhiên trong `sequence_number`, chúng ta có thể phải xử lý UUID một chút. Chúng ta cần loại bỏ 2 bit biến thể cao. Script trích xuất:
def extract_sequence_number(uuid_obj):
sequence_high = uuid_obj.fields[3] # high_sequence_variant (8 byte)
sequence_low = uuid_obj.fields[4] # low_sequence (8 byte)
return ((sequence_high & 0x3f) << 8) | sequence_low
generated_uuids = [uuid.uuid1() for _ in range(1427)] # danh sách UUIDv1 thu thập
sequences = [extract_sequence_number(u) for u in generated_uuids] # danh sách số ngẫu nhiên máy chủ sinh
Sau đó, chúng ta sử dụng pyrandcrack để giải ma trận: https://github.com/guoql666/pyrandcracker
Vậy còn lại ba field dựa trên thời gian.
Trước hết, timestamp này ở cấp độ nano giây. Điều đó có nghĩa là, để dự đoán chính xác thời điểm máy nạn nhân tạo UUID này, chúng ta phải dự đoán chính xác đến nano giây. Rõ ràng điều này là không thể. Vì vậy, chúng ta cần giới thiệu một phương pháp tấn công: Sandwhich Attack (gọi tắt là SWATK)
Đúng như tên gọi, quy trình SWATK là: trước tiên lấy một UUID, sau đó khiến máy nạn nhân tạo UUID mục tiêu, rồi lại lấy một UUID nữa. Như vậy, UUID mục tiêu nhìn từ góc độ thời gian bị kẹp giữa hai UUID đã biết, nên gọi là Sandwhich Attack.
Thông qua hai timestamp đã biết phía trước và sau, chúng ta có thể xác định phạm vi thời gian tạo UUID mục tiêu, từ đó dự đoán bằng cách bạo lực. Hacktrick có giải thích chi tiết hơn: https://book.hacktricks.wiki/zh/pentesting-web/uuid-insecurities.html
Có exp cho SWATK: https://github.com/Lupin-Holmes/sandwich
Cũng có blog nói về bảo mật thực tiễn của UUIDv1: https://www.landh.tech/blog/20230811-sandwich-attack/
Lưu ý: trong các blog này, field `sequence_number` của UUID bị tấn công là giá trị cố định, nghĩa là họ đã bỏ qua bước tấn công PRNG vừa thảo luận. Trong thực tế, điều này hầu như không xảy ra.
Vì vậy, đối với python UUID1, luồng tấn công của chúng ta大概 như sau:
- Thu thập 1427 UUID được sinh liên tục, giải ma trận MT19937. Giải được nội dung hai field tương ứng với `sequence_number`.
- Chọn bất kỳ UUID nào trong mẫu trên để lấy địa chỉ MAC không đổi (hoặc node ngẫu nhiên) để có được giá trị field node.
- Lấy hai UUID trước và sau UUID mục tiêu, thực hiện tấn công SWATK bạo lực timestamp ở giữa, giải được giá trị field thời gian.
Về luồng tấn công này, tôi đã tải lên GitHub một số hướng dẫn và giải thích thêm: https://github.com/LamentXU123/cve/blob/main/UUID.md
UUIDv6
UUIDv6 về cơ bản là phiên bản tối ưu của UUIDv1. Mã:
def generate_uuid6(node_identifier=None, sequence_number=None):
"""Tương tự uuid1 nhưng các field được sắp xếp khác nhau để cải thiện locality DB."""
global _previous_timestamp_v6
import time
nano_seconds = time.time_ns()
# 0x01b21dd213814000 là số khoảng thời gian 100ns giữa
# UUID epoch 1582-10-15 00:00:00 và Unix epoch 1970-01-01 00:00:00.
timestamp_value = nano_seconds // 100 + 0x01b21dd213814000
if _previous_timestamp_v6 is not None and timestamp_value <= _previous_timestamp_v6:
timestamp_value = _previous_timestamp_v6 + 1
_previous_timestamp_v6 = timestamp_value
if sequence_number is None:
import random
sequence_number = random.getrandbits(14) # thay thế cho bộ nhớ ổn định
high_time_mid = (timestamp_value >> 12) & 0xffff_ffff_ffff
low_time = timestamp_value & 0x0fff # giữ 12 bit và xóa bit phiên bản
sequence_val = sequence_number & 0x3fff # giữ 14 bit và xóa bit biến thể
if node_identifier is None:
node_identifier = retrieve_node_id()
# --- 32 + 16 --- -- 4 -- -- 12 -- -- 2 -- -- 14 --- 48
# high_time_mid | version | low_time | variant | sequence_number | node
numeric_uuid_6 = high_time_mid << 80
numeric_uuid_6 |= low_time << 64
numeric_uuid_6 |= sequence_val << 48
numeric_uuid_6 |= node_identifier & 0xffff_ffff_ffff
# do xây dựng, bit biến thể và phiên bản đã được xóa
numeric_uuid_6 |= _RFC_4122_VERSION_6_FLAGS
return UUID._from_int(numeric_uuid_6)
Điểm khác biệt duy nhất giữa UUIDv6 và UUIDv1 là cách xử lý timestamp. UUIDv1 sử dụng `1582-10-15 00:00:00` làm điểm bắt đầu timestamp. Trong khi đó UUIDv6 quay trở lại `1970-01-01 00:00:00` cổ điển. Ngoài ra, cả hai có sự khác biệt trong cách xử lý timestamp. Nhưng quy trình tấn công về cơ bản giống nhau. Chỉ cần điều chỉnh đơn giản thuật toán khi thực hiện SWATK. Không trình bày thêm ở đây.
UUIDv8
So với một tiêu chuẩn chuẩn mực, UUIDv8 giống như một khái niệm hơn. Nó rất tùy chỉnh. Triển khai UUIDv8 trong Python như sau:
def generate_uuid8(x=None, y=None, z=None):
"""Tạo UUID từ ba block tùy chỉnh."""
if x is None:
import random
x = random.getrandbits(48)
if y is None:
import random
y = random.getrandbits(12)
if z is None:
import random
z = random.getrandbits(62)
numeric_uuid_8 = (x & 0xffff_ffff_ffff) << 80
numeric_uuid_8 |= (y & 0xfff) << 64
numeric_uuid_8 |= z & 0x3fff_ffff_ffff_ffff
# do xây dựng, bit biến thể và phiên bản đã được xóa
numeric_uuid_8 |= _RFC_4122_VERSION_8_FLAGS
return UUID._from_int(numeric_uuid_8)
Trong đó, một dự án an toàn sẽ tự định nghĩa các tham số `x`, `y` và `z`. Tuy nhiên, nếu máy chủ không nhập bất kỳ tham số nào, trực tiếp sử dụng cấu hình mặc định None (tức là gọi trực tiếp `uuid.generate_uuid8()`), thì có thể nói UUIDv8 trong Python cực kỳ không an toàn. Vì mỗi phần đều sử dụng số giả ngẫu nhiên. Theo rò rỉ một lần 126 byte, **kẻ tấn công chỉ cần thu được 159 UUIDv8 được sinh liên tục là có thể hoàn toàn dự đoán UUID tiếp theo.** Thậm chí không cần thực hiện SWATK do vấn đề độ chính xác timestamp. Chúng ta có thể nói UUIDv8 không tự định nghĩa là cực kỳ nguy hiểm.
Mà phần lớn các nhà phát triển không hiểu rõ UUID khi chọn UUID thường sẽ chọn theo số phiên bản. UUIDv8 với tư cách là UUID mới nhất, thường trở thành lựa chọn của người mới bắt đầu. Tuy nhiên, việc thay đổi PRNG thành CSPRNG ở đây sẽ phải chịu mức giảm hiệu suất lên đến 15625 lần! Do đó, Python sử dụng thuật toán MT19937 dễ phá vỡ. Trong trường hợp rò rỉ nhiều byte như vậy, việc không tự định nghĩa tham số sử dụng UUIDv8 là cực kỳ nguy hiểm.
Vì vậy, tôi đã sửa đổi tài liệu trong python3.13+ mới nhất, tuyên bố về sự không an toàn của hàm UUIDv8.
UUIDv3 và UUIDv5
def generate_uuid3(namespace, name):
"""Tạo UUID từ hash MD5 của UUID namespace và tên."""
if isinstance(name, str):
name = bytes(name, "utf-8")
import hashlib
h = hashlib.md5(namespace.bytes + name, usedforsecurity=False)
numeric_uuid_3 = int.from_bytes(h.digest())
numeric_uuid_3 &= _RFC_4122_CLEARFLAGS_MASK
numeric_uuid_3 |= _RFC_4122_VERSION_3_FLAGS
return UUID._from_int(numeric_uuid_3)
def generate_uuid5(namespace, name):
"""Tạo UUID từ hash SHA-1 của UUID namespace và tên."""
if isinstance(name, str):
name = bytes(name, "utf-8")
import hashlib
h = hashlib.sha1(namespace.bytes + name, usedforsecurity=False)
numeric_uuid_5 = int.from_bytes(h.digest()[:16])
numeric_uuid_5 &= _RFC_4122_CLEARFLAGS_MASK
numeric_uuid_5 |= _RFC_4122_VERSION_5_FLAGS
return UUID._from_int(numeric_uuid_5)
Hai thuật toán này trong thiết kế đều áp dụng các thuật toán hash an toàn. uuid3 và uuid5 dựa trên giá trị hash của một đầu vào cố định, do đó mỗi UUIDv3 tạo ra từ mỗi đầu vào đều cố định, UUIDv5 cũng vậy. Sự khác biệt giữa chúng là phiên bản trước sử dụng `MD5`, trong khi phiên bản sau sử dụng `sha1`.
Đối với hai thuật toán này, ngoài các phương pháp tấn công truyền thống như va chạm hash (brute force) và rò rỉ đầu vào gốc, tôi không nghĩ ra phương pháp tấn công nào khác.
UUIDv4
Phiên bản UUID an toàn nhất.
def generate_uuid4():
"""Tạo UUID ngẫu nhiên."""
numeric_uuid_4 = int.from_bytes(os.urandom(16))
numeric_uuid_4 &= _RFC_4122_CLEARFLAGS_MASK
numeric_uuid_4 |= _RFC_4122_VERSION_4_FLAGS
return UUID._from_int(numeric_uuid_4)
Sử dụng bộ sinh số ngẫu nhiên an toàn, hoàn toàn sinh ngẫu nhiên một UUID. Cái này thật sự không thể tấn công được.
UUIDv7
def generate_uuid7():
"""Tạo UUID từ timestamp Unix tính bằng mili giây và bit ngẫu nhiên."""
global _previous_timestamp_v7
global _previous_counter_v7
nano_seconds = time.time_ns()
timestamp_ms = nano_seconds // 1_000_000
if _previous_timestamp_v7 is None or timestamp_ms > _previous_timestamp_v7:
counter_val, tail_data = _uuid7_get_counter_and_tail()
else:
if timestamp_ms < _previous_timestamp_v7:
timestamp_ms = _previous_timestamp_v7 + 1
# tăng counter 42-bit
counter_val = _previous_counter_v7 + 1
if counter_val > 0x3ff_ffff_ffff:
# tăng timestamp 48-bit
timestamp_ms += 1
counter_val, tail_data = _uuid7_get_counter_and_tail()
else:
# dữ liệu ngẫu nhiên 32-bit
tail_data = int.from_bytes(os.urandom(4))
unix_timestamp_ms = timestamp_ms & 0xffff_ffff_ffff
counter_msb = counter_val >> 30
# giữ 12 bit MSB của counter và xóa bit biến thể
counter_high = counter_msb & 0x0fff
# giữ 30 bit LSB của counter và xóa bit phiên bản
counter_low = counter_val & 0x3fff_ffff
# đảm bảo tail luôn là số nguyên 32-bit
tail_data &= 0xffff_ffff
numeric_uuid_7 = unix_timestamp_ms << 80
numeric_uuid_7 |= counter_high << 64
numeric_uuid_7 |= counter_low << 32
numeric_uuid_7 |= tail_data
# do xây dựng, bit biến thể và phiên bản đã được xóa
numeric_uuid_7 |= _RFC_4122_VERSION_7_FLAGS
result = UUID._from_int(numeric_uuid_7)
# trì hoãn cập nhật global cho đến khi hoàn tất tính toán
_previous_timestamp_v7 = timestamp_ms
_previous_counter_v7 = counter_val
return result
Về mặt bảo mật, UUIDv7 so với UUIDv1 và UUIDv6 sử dụng CSPRNG. Điều này khiến tính ngẫu nhiên của field tail không thể bị khai thác. Ngay cả khi phần dựa trên thời gian có thể bị tấn công bằng SWATK, nhưng không thể phá vỡ phần bộ sinh số ngẫu nhiên, thuật toán này là an toàn.
UUIDv2
Thư viện chuẩn Python dường như không hỗ trợ UUIDv2. v2 tương tự v1, nhưng nó thay thế một phần thông tin thời gian trong V1 bằng tên máy chủ, có rủi ro riêng tư. Vì vậy không được Python triển khai.