Modbus và một số công cụ kiểm thử

Modbus và một số công cụ kiểm thử

English version
Modbus là một giao thức lâu đời, tồn tại từ năm 1979. Được sử dụng chủ yếu trong các hệ thống điều kiển công nghiệp ICS/SCADA. Trong bài viết này tôi sẽ nói qua về kiến trúc của các gói tin modbus và sử dụng để viết và so sánh một số công cụ fuzzing với giao thức này.

1. Modbus frame & ADU

Trong họ các giao thức modbus, các giao thức con được sử dụng nhiều nhất là modbus TCP và modbus RTU (qua serial RS232, RS485). Trong đó, mỗi gói tin có cấu trúc khác nhau, mô tả dưới đây.

Hình 1.1. Gói tin modbus RTU [1]

Trong hệ thống modbus master-slave sử dụng một serial bus làm kênh truyền. Mỗi gói tin modbus (frame) bao gồm 1 byte để định danh slave. 2 byte cho mã CRC và PDU (Protocol Data Unit).

Trong đó, PDU là đơn vị chức năng chứa dữ liệu modbus, bao gồm mã hàm (function code) và dữ liệu đi kèm, chúng ta sẽ nói về chi tiết chức năng của PDU sau. Các dữ liệu còn lại (địa chỉ slave & CRC) là các dữ liệu ngoại vi dành riêng cho giao thức con. PDU và các dữ liệu ngoại vi hợp thành một modbus frame, hay còn được gọi là ADU (Application Data Unit). Vì mỗi giao thức con của modbus yêu cầu các dữ liệu ngoại vi khác nhau, mỗi giao thức con của modbus sẽ có một ADU riêng, PDU có cấu trúc không đổi. Trong bài này chỉ đề cập đến modbus RTU vs modbus TCP.

Các trường dữ liệu của modbus RTU được tóm gọn trong bảng sau.

Tên Độ dài (byte) Chức năng
Bắt đầu gói tin 3.5 28 bits trống giữa các gói tin
Địa chỉ 1 Địa chỉ của slave
Function code 1 Mã hàm (Function code)
Dữ liệu n Dữ liệu (n dài tối đa 252 byte)
CRC 2 CRC

ADU của modbus TCP có khác biệt đôi chút, vì việc kiểm tra độ toàn vẹn của gói tin đã được chuyển cho lớp TCP/IP, modbus TCP ADU không còn chứa CRC data. Thay vào đó là MBAP header.

Hình 1.2. modbus TCP ADU [1]

Các trường của MBAP:

  • Transaction identifier: Định danh thứ tự cho các gói tin. Ví dụ, khi master gửi các request có thứ tự 1, 2, 3. Slave có thể gửi lại các request có thứ tự bất kì như 2, 1, 3. Master khi đó có thể sắp xếp lại các gói tin để thực hiện đúng yêu cầu.
  • Protocol identifier: Định danh giao thức, với modbus nói chung, trường này có giá trị là 0.
  • Độ dài: Trường này giúp xác định số byte còn lại trong gói tin modbus này.
  • Unit identifier: Trường này thường không được sử dụng trong các thiết bị thuần TCP/IP (giá trị 0). Nhưng modbus có thể có nhiều thiết bị gateway trên một kênh truyền, khi đó trường này có thể sử dụng để biến đổi modbus TCP sang giao thức con khác, ví dụ modbus RTU, khi đó trường này được sử dụng để chứa địa chỉ đích của slave.

Các trường dữ liệu của modbus TCP được tóm gọn trong bảng sau

Tên Độ dài (byte) Chức năng
Transaction identifier 2 Thứ tự sử dụng đồng bộ gói tin
Protocol identifier 2 Modbus/TCP = 0
Độ dài 2 Độ dài còn lại của gói tin này
Unit identifier 1 Địa chỉ server (255 if not used)
Function code 1 Function code
Dữ liệu n Dữ liệu (n tối đa = 252)

2. Modbus data object

Trước khi đi vào chi tiết của PDU, ta cần hiểu được cách dữ liệu được lưu trong một thiết bị modbus.

Giao thức modbus định nghĩa 4 khối bộ nhớ: coil, discrete input, input register và holding register. Mỗi khối có kích cỡ và quyền truy cập cho master/slave khác nhau. Tuy nhiên nhà sản xuất có thể tự chọn cách implement các khối bộ nhớ này. Ví dụ NSX có thể cho 2 khối bộ nhớ cùng trỏ đến 1 vùng nhớ trong thiết bị.

Khối bộ nhớ Kiểu dữ liệu Quyền truy cập của master Quyền truy cập của slave
Coils Boolean Read/Write Read/Write
Discrete Inputs Boolean Read-only Read/Write
Holding Registers Unsigned Word (2 bytes) Read/Write Read/Write
Input Registers Unsigned Word (2 bytes) Read-only Read/Write

Các khối bộ nhớ có 16 bit địa chỉ, tức là mỗi khối bộ nhớ có thể có đến 65536 'thành phần', địa chỉ từ 0 -> 65535.  Chú ý khi nói về các thành phần của khối bộ nhớ, thứ tự được bắt đầu từ 1. Ví dụ coil 1 có địa chỉ là 0 trong khối bộ nhớ coil.

NSX cũng có thể chọn không implement toàn bộ 65536 thành phần của mỗi khối bộ nhớ, do đó một thiết bị có thể chỉ có 2 coil và 3 holding register.

Để truy cập các khối bộ nhớ trên slave, master cần đưa đúng địa chỉ của các thành phần, để phân biệt địa chỉ của coil, register...Mỗi khối bộ nhớ có một prefix riêng biệt được thêm vào địa chỉ:

Khối bộ nhớ Prefix
Coils 0
Discrete Inputs 1
Input Registers 3
Holding Registers 4

Ví dụ, để truy cập Discrete Inputs số 3 -> sử dụng địa chỉ 100002.

3. Modbus PDU & cách thức truyền lệnh

Thiết bị modbus slave được điều khiển qua đọc ghi và thay đổi dữ liệu trong các khối bộ nhớ, sử dụng câu lệnh và địa chỉ trong PDU. PDU bao gồm mã hàm (1 byte) và dữ liệu (252 byte).

Có 2 loại PDU, phân biệt theo mã hàm:

  • Public code: Các mã này được định nghĩa theo tài liệu của modbus ([2] - chương 5 & 6).
  • User defined code: Các mã này có thể được định nghĩa bởi NSX.

Các mã hàm được định nghĩa như sau:

Hình 3.1. Các mã hàm [2]

Trình tự truyền lệnh của một modbus request như sau:

Hình 3.2. Trình tự truyền lệnh - không lỗi [2]

Ảnh trên thể hiện quá trình truyền lệnh của một modbus request khi không có lỗi xảy ra tron quá trình xử lý. Slave (server) trả lại request có mã hàm có giá trị giống mã hàm được gửi lên. Trường dữ liệu được gửi kèm lại giống dữ liệu ban đầu.

response function code = request function code

Nếu có lỗi xảy ra, mã hàm được trả về là mã hàm được gửi lên + 128 (giá trị từ 128 -> 255). Trường dữ liệu được gửi lại là mã lỗi. Mã lỗi được định nghĩa trong tài liệu của modbus [2]. Mỗi mã hàm có một số mã lỗi được hỗ trợ, định nghĩa trong chương 6, mã lỗi được định nghĩa trong chương 7.

response function code = request function code + 128

Hình 3.3. Trình tự truyền lệnh - có lỗi [2]

4. Fuzzing modbus

Khi kiểm thử một thiết bị sử dụng modbus, việc tìm được tất cả các function code nó hỗ trợ giúp tăng hiệu quả cũng như tăng khả năng ra lỗi, đặc biệt trong các function ẩn. Cấu trúc modbus request cũng khá đơn giản - binary protocol với một số trường nhất định -> dễ dàng fuzz với các tool có sẵn. Dưới đây tôi sẽ chọn 3 tool để thử với một số target và so sánh tốc độ fuzz của các tool này.

Kịch bản fuzz: Một chương trình modbus test được sử dụng để mô phỏng thiết bị thực tế. Fuzzer không được tiếp cận source code hay emulate binary, payload phải được truyền qua network.

4.1. Boofuzz

Boofuzz là một công cụ sử dụng chủ yếu cho blackbox fuzzing network protocol. Đã có bài viết mô tả sơ qua cách sử dụng boofuzz để fuzz modbus [3]. Tôi sẽ sử dụng lại code của server trong bài viết này để dễ dàng so sánh với các tool khác.

Code server dưới đây sẽ 'crash' khi nhận được yêu cầu từ server đọc input register có địa chỉ > 255:

#!/usr/bin/env python3

from pymodbus.datastore import ModbusSequentialDataBlock
from pymodbus.server.sync import StartTcpServer
from pymodbus.datastore import ModbusSlaveContext, ModbusServerContext

import logging
import os

##
# A sloppy data block: someone forgot to check some address bounds
# somewhere, and reading input registers past 0xFF is going to cause
# this device to crash!
class BadDataBlock(ModbusSequentialDataBlock):
    def __init__(self):
        self.values = [0x00] * 0xFFFF
        super().__init__(0, self.values)

    def getValues(self, addr, count):
        # Uh-oh...
        if (addr <= 0xFF):
            return self.values[addr:addr+count]
        else:
            os._exit(1)

def run_server():

    bad_block = BadDataBlock()

    store = ModbusSlaveContext(
        di=ModbusSequentialDataBlock(0, [0xFF] * 32),
        co=ModbusSequentialDataBlock(0, [0xFF] * 32),
        hr=ModbusSequentialDataBlock(0, [0xFF] * 32),
        ir=bad_block)

    context = ModbusServerContext(slaves=store, single=True)

    StartTcpServer(context, address=("localhost", 5020))

def main():
    FORMAT = ('%(asctime)-15s %(threadName)-15s'
                    ' %(levelname)-8s %(module)-15s:%(lineno)-8s %(message)s')
    logging.basicConfig(format=FORMAT)
    log = logging.getLogger()
    log.setLevel(logging.DEBUG)
    run_server()

if __name__ == "__main__":
    main()

Code để fuzz sử dụng boofuzz ([3]). Chú ý trong code này server không được theo dõi bằng bất kỳ cách nào, do đó fuzzer có thể nhầm lỗi đường truyền với target crash và thoát.

#!/usr/bin/env python3

from boofuzz import *

def main():
    session = Session(target=Target(connection=TCPSocketConnection("127.0.0.1", 5020)),
                      restart_threshold=1, restart_timeout=1.0)

    ##
    # Modbus TCP header:
    # * 2-byte transaction id
    # * 2-byte protocol id, must be 0000
    # * 2-byte message length
    # * 1 byte unit ID
    #
    # followed by request data:
    # * 1 byte function ID
    # * 2-byte starting address
    # * 2-byte number of registers
    s_initialize(name="Read Input Registers")

    if s_block_start("header"):
        # Transaction ID
        s_bytes(value=bytes([0x00, 0x01]), size=2, max_len=2, name="transaction_id")
        # Protocol ID. Fuzzing this usually doesn't provide too much value so let's leave it fixed.
        # We can also use s_static here.
        s_bytes(value=bytes([0x00, 0x00]), size=2, max_len=2, name="proto_id", fuzzable=False)
        # Length. Fuzzing this is generally useful but we'll keep it fixed to make this tutorial's
        # data set easier to explore.
        s_bytes(value=bytes([0x00, 0x06]), size=2, max_len=2, name="len", fuzzable=False)
        # Unit ID. Once again, fuzzing this usually doesn't provide too much value.
        s_bytes(value=bytes([0x01]), size=1, max_len=1, name="unit_id", fuzzable=False)
    s_block_end()

    if s_block_start("data"):
        # Function ID. Fixed to Read Input Registers (04)
        s_bytes(value=bytes([0x04]), size=1, max_len=1, name="func_id", fuzzable=False)
        # Address of the first register to read
        s_bytes(value=bytes([0x00, 0x00]), size=2, max_len=2, name="start_addr")
        # Number of registers to read
        s_bytes(value=bytes([0x00, 0x01]), size=2, max_len=2, name="reg_num")
    s_block_end()

    session.connect(s_get("Read Input Registers"))

    session.fuzz()

if __name__ == "__main__":
    main()

Do tính ngẫu nhiên của thuật toán sinh trong fuzzer, thời gian 'crash' của server khác nhau với mỗi lần chạy. Nhưng tổng thể fuzzer chạy khá nhanh do không cần theo dõi target.

4.2. AFLNET (forked)

Tôi thường sử dụng AFL cho việc fuzzing các target sử dụng binary data. Tuy nhiên AFL không hỗ trợ fuzz gửi payload qua network.

Vào thời điểm viết bài, AFL++ devs recommend AFLNET. Tuy nhiên nó cũng không hỗ trợ modbus hay fuzz target black box. Do vậy tôi đã thêm các tính năng trên vào một fork riêng, trong tương lai có thể sẽ được merge và phát triển thêm, nên bạn đọc có thể sử dụng AFLNET thay vì link trên tiêu đề chương.

AFLNET đã hỗ trợ fuzz qua network, việc hỗ trợ thêm modbus protocol không quá khó, tôi có hướng dẫn sử dụng fuzz với target local đã instrument tại đây.

Hướng dẫn cho fuzz target blackbox qua network tại đây.

Trong thời điểm hiện tại bản fork này có theo dõi trạng thái của target và có thể sử dụng thêm script để restart, reset target.

Kết quả sau khi chạy AFLNET với chương trình test bên trên:

Hình 4.2. AFLNET test remote-only

Kết quả crash sau khi được triage không liên quan đến 'lỗi' đã được cài vào chương trình test. Payload được sinh ra trigger exception trong thư viện pymodbus, dẫn đến việc reset kết nối giữa target và fuzzer. Dưới góc nhìn của fuzzer thì đúng là target đã crash, thành công!?

Bạn đọc cũng có thể thấy tốc độ fuzz vô cùng chậm, 8 request / s.  Lý do là script sử dụng để monitor / reset target hiện tại đang sử dụng bash script và system() call. Trong tương lai có thể được phát triển thêm bằng cách sử dụng c để viết monitor, hoặc thêm một chức năng tương tự fork-server.

Chú ý: Trong chế độ này kết quả của AFLNET có thể sai lệch do sử dụng kết quả gửi và nhận để xác định target đã crash hay chưa. Kết quả cũng có thể bị ảnh hưởng do việc hoàn toàn dựa vào thuật toán của AFLNET và không instrument target.

4.3. So sánh AFLNET và boofuzz

Với remote target, hiện tại tốc độ của boofuzz ngang ngửa AFLNET nếu không sử dụng script, tuy nhiên lại mất đi khả năng restart và monitor server. AFLNET có thể nhỉnh hơn vì có sự dụng thuật toán phức tạp hơn sinh ngẫu nhiên như boofuzz nhưng ta sẽ không so sánh ở đây vì không có nhiều dữ liệu so sánh qua việc fuzz 1 tiếng với 1 target.

Với local target (target có thể monitor local / instrumented), kết quả lại khác hoàn toàn. Tôi sẽ sử dụng một chương trình test trong libmodbus (https://libmodbus.org/) để so sánh.

Setup cho AFLNET là link target local đã được nêu bên trên. Dưới đây là code fuzzer của boofuzz được sửa lại cho test này. Sử dụng boofuzz process monitor.

#!/usr/bin/env python3

from boofuzz import *
import time
import os, sys
import signal

def main():
    ip = "127.0.0.1"
    procmonport = 26002
    port = 1502
    start_cmd = os.getcwd() + "/random-test-server"
    stop_cmd = 'bash -c "kill -9 $(ps -aux | grep \[r\]andom-test-server |  awk \'{print $2}\')"'
    options = {"stop_commands": [stop_cmd], "start_commands": [start_cmd]}
    procmon = ProcessMonitor(ip, procmonport)
    procmon.set_options(**options)
    monitors = [procmon]
    session = Session(target=Target(connection=TCPSocketConnection(ip, port), restart_threshold=1, restart_timeout=1.0, monitors=monitors))

    ##
    # Modbus TCP header:
    # * 2-byte transaction id
    # * 2-byte protocol id, must be 0000
    # * 2-byte message length
    # * 1 byte unit ID
    #
    # followed by request data:
    # * 1 byte function ID
    # * 2-byte starting address
    # * 2-byte number of registers
    s_initialize(name="Read Input Registers")

    if s_block_start("header"):
        # Transaction ID
        s_bytes(value=bytes([0x00, 0x01]), size=2, max_len=2, name="transaction_id")
        # Protocol ID. Fuzzing this usually doesn't provide too much value so let's leave it fixed.
        # We can also use s_static here.
        s_bytes(value=bytes([0x00, 0x00]), size=2, max_len=2, name="proto_id", fuzzable=False)
        # Length. Fuzzing this is generally useful but we'll keep it fixed to make this tutorial's
        # data set easier to explore.
        s_bytes(value=bytes([0x00, 0x06]), size=2, max_len=2, name="len", fuzzable=False)
        # Unit ID. Once again, fuzzing this usually doesn't provide too much value.
        s_bytes(value=bytes([0x01]), size=1, max_len=1, name="unit_id", fuzzable=False)
    s_block_end()

    if s_block_start("data"):
        # Function ID. Fixed to Read Input Registers (04)
        s_bytes(value=bytes([0x04]), size=1, max_len=1, name="func_id", fuzzable=False)
        # Address of the first register to read
        s_bytes(value=bytes([0x00, 0x00]), size=2, max_len=2, name="start_addr")
        # Number of registers to read
        s_bytes(value=bytes([0x00, 0x01]), size=2, max_len=2, name="reg_num")
    s_block_end()

    session.connect(s_get("Read Input Registers"))

    session.fuzz()

if __name__ == "__main__":
    main()

Kết quả AFLNET chạy xấp xỉ 40 request / s. Vẫn chậm so với fuzz thuần binary truyền thống. Bằng cách tinh chỉnh lại các timeout argument kết quả có thể được cải thiện đôi chút (không đáng kể).

Với boofuzz kết quả không khả quan lắm.

Điều tra kỹ hơn một chút thì thấy kết quả thấp như thế này do chương trình test thoát khi kết nối bên client đóng. Với mỗi request chương trình lại phải bắt đầu từ đầu.

Thử lại boofuzz local với chương trình python đầu tiên (không thoát khi kết nối client đóng). Kết quả cũng chỉ ở mức 1 -> 2 request / s.

4.4. MTF-Storm

Tool này khá là khó sử dụng do thiếu tài liệu, trong quá trình test (chạy 1 tiếng với chương trình mẫu) fuzzer chạy chậm và không phát hiện được crash nào. Payload cuối cùng không khác mấy khi so với payload đầu tiên (1 chuỗi dài 0x55).

Tuy nhiên tool này có thể được sử dụng trong quá trình recon, sử dụng chức năng dump và probe, tool có thể phát hiện và list các function code khá nhanh.

Tài liệu tham khảo