From 5e48c1823c14d12279a8fff1d507afd85870ad7e Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Mon, 6 Jul 2026 13:15:36 -0500 Subject: [PATCH 1/5] Add drda module for IBM DB2 / DRDA database servers Adds a new `drda` scan module that probes DRDA (Distributed Relational Database Architecture) database servers on TCP port 50000. DRDA is spoken by IBM DB2 (and Apache Derby / Informix). The module sends a DRDA EXCSAT ("Exchange Server Attributes") request and parses the EXCSATRD reply, decoding the EBCDIC-encoded attributes into structured output: server class / platform (e.g. QDB2/NT64), instance name, product release level (e.g. SQL11013), a human-readable version (11.01.3), and external name. This mirrors the data surfaced by Shodan and nmap's drda-info script. Includes: - modules/drda: scanner, DRDA packet build/parse, EBCDIC decoding, unit tests - zgrab2_schemas/zgrab2/drda.py: output schema - integration_tests/drda: mock DRDA server container + test.sh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- integration_tests/docker-compose.yml | 13 ++ integration_tests/drda/container/Dockerfile | 7 + integration_tests/drda/container/server.py | 138 +++++++++++++++++ integration_tests/drda/test.sh | 57 +++++++ modules/drda.go | 10 ++ modules/drda/drda.go | 162 ++++++++++++++++++++ modules/drda/drda_test.go | 128 ++++++++++++++++ modules/drda/scanner.go | 138 +++++++++++++++++ zgrab2_schemas/zgrab2/__init__.py | 1 + zgrab2_schemas/zgrab2/drda.py | 42 +++++ 10 files changed, 696 insertions(+) create mode 100644 integration_tests/drda/container/Dockerfile create mode 100644 integration_tests/drda/container/server.py create mode 100755 integration_tests/drda/test.sh create mode 100644 modules/drda.go create mode 100644 modules/drda/drda.go create mode 100644 modules/drda/drda_test.go create mode 100644 modules/drda/scanner.go create mode 100644 zgrab2_schemas/zgrab2/drda.py diff --git a/integration_tests/docker-compose.yml b/integration_tests/docker-compose.yml index 654a8a85..d8689c7a 100644 --- a/integration_tests/docker-compose.yml +++ b/integration_tests/docker-compose.yml @@ -74,6 +74,14 @@ services: CHECKPOINT_HOST: "fw1.example.com" CHECKPOINT_DOMAIN: "example.com" + drda: + build: + context: ./drda/container + container_name: "zgrab_drda" + networks: + - drda-network + hostname: "target" + ftp: build: context: ./ftp/container @@ -788,3 +796,8 @@ networks: ipam: config: - subnet: 100.64.0.192/30 + drda-network: + driver: bridge + ipam: + config: + - subnet: 100.64.0.200/30 diff --git a/integration_tests/drda/container/Dockerfile b/integration_tests/drda/container/Dockerfile new file mode 100644 index 00000000..87a58088 --- /dev/null +++ b/integration_tests/drda/container/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.14.5-slim-bookworm + +COPY server.py /server.py + +EXPOSE 50000 + +ENTRYPOINT ["python3", "/server.py"] diff --git a/integration_tests/drda/container/server.py b/integration_tests/drda/container/server.py new file mode 100644 index 00000000..86d21bd9 --- /dev/null +++ b/integration_tests/drda/container/server.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +Minimal DRDA (Distributed Relational Database Architecture) server for +integration testing the zgrab2 drda module. + +DRDA is the wire protocol spoken by IBM DB2 (and Apache Derby / Informix) on +TCP port 50000. This mock answers a DRDA EXCSAT ("Exchange Server Attributes") +request with a fixed EXCSATRD reply carrying deterministic, EBCDIC-encoded +server attributes, mirroring the response of a real IBM DB2 11.1 server. + +Wire layout of a DDM message: + [length:2][magic:1=0xD0][format:1][correlation-id:2][length2:2][codepoint:2] + followed by nested parameters, each: [length:2][codepoint:2][data...] +""" + +import socket +import struct +import threading +import time + +PORT = 50000 + +# DDM / parameter code points. +CP_EXCSAT = 0x1041 +CP_EXCSATRD = 0x1443 +CP_EXTNAM = 0x115E +CP_SRVCLSNM = 0x1147 +CP_SRVNAM = 0x116D +CP_SRVRLSLV = 0x115A + +# Deterministic attributes served to the scanner. These mirror what Shodan/nmap +# surface for a real IBM DB2 11.1 server, minus any per-connection token. +SERVER_CLASS = "QDB2/NT64" # SRVCLSNM: server platform +INSTANCE_NAME = "DB2" # SRVNAM: instance name +RELEASE_LEVEL = "SQL11013" # SRVRLSLV: -> version 11.01.3 +EXTERNAL_NAME = "DB2 db2sysc 00000000%FED%Y00" # EXTNAM + +# EBCDIC (code page 500) translation table, ASCII index -> EBCDIC byte. This is +# the inverse of the e2a table used by the zgrab2 drda module. +E2A_HEX = ( + "000102039C09867F978D8E0B0C0D0E0F101112139D8508871819928F1C1D1E1F" + "80818283840A171B88898A8B8C050607909116939495960498999A9B14159E1A" + "20A0A1A2A3A4A5A6A7A8D52E3C282B7C26A9AAABACADAEAFB0B121242A293B5E" + "2D2FB2B3B4B5B6B7B8B9E52C255F3E3FBABBBCBDBEBFC0C1C2603A2340273D22" + "C3616263646566676869C4C5C6C7C8C9CA6A6B6C6D6E6F707172CBCCCDCECFD0" + "D17E737475767778797AD2D3D45BD6D7D8D9DADBDCDDDEDFE0E1E2E3E45DE6E7" + "7B414243444546474849E8E9EAEBECED7D4A4B4C4D4E4F505152EEEFF0F1F2F3" + "5C9F535455565758595AF4F5F6F7F8F930313233343536373839FAFBFCFDFEFF" +) +_e2a = bytes.fromhex(E2A_HEX) +_a2e = bytearray(256) +for _e in range(256): + _a2e[_e2a[_e]] = _e + + +def to_ebcdic(s: str) -> bytes: + return bytes(_a2e[ord(c)] for c in s) + + +def make_param(codepoint: int, data: bytes) -> bytes: + return struct.pack(">HH", len(data) + 4, codepoint) + data + + +def build_excsatrd() -> bytes: + params = ( + make_param(CP_EXTNAM, to_ebcdic(EXTERNAL_NAME)) + + make_param(CP_SRVCLSNM, to_ebcdic(SERVER_CLASS)) + + make_param(CP_SRVNAM, to_ebcdic(INSTANCE_NAME)) + + make_param(CP_SRVRLSLV, to_ebcdic(RELEASE_LEVEL)) + ) + total = 10 + len(params) + # length, magic(0xD0), format, correlation-id, length2, codepoint + ddm = struct.pack(">HBBHHH", total, 0xD0, 0x03, 1, total - 6, CP_EXCSATRD) + return ddm + params + + +EXCSATRD = build_excsatrd() + + +def read_ddm(conn: socket.socket) -> bytes: + header = b"" + while len(header) < 2: + chunk = conn.recv(2 - len(header)) + if not chunk: + return b"" + header += chunk + length = struct.unpack(">H", header)[0] + body = header + while len(body) < length: + chunk = conn.recv(length - len(body)) + if not chunk: + return b"" + body += chunk + return body + + +def handle_client(conn: socket.socket, addr): + print(f"Connection from {addr}", flush=True) + try: + req = read_ddm(conn) + if len(req) < 10: + print(f"Short request from {addr}: {req.hex()}", flush=True) + return + codepoint = struct.unpack(">H", req[8:10])[0] + if codepoint != CP_EXCSAT: + print( + f"Unexpected request codepoint 0x{codepoint:04x} from {addr}", + flush=True, + ) + return + conn.sendall(EXCSATRD) + print(f"Served EXCSATRD to {addr}", flush=True) + # Let the scanner finish reading before we close. + time.sleep(1) + except Exception as e: + print(f"Error handling {addr}: {e}", flush=True) + finally: + conn.close() + + +def main(): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv: + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("0.0.0.0", PORT)) + srv.listen(16) + print( + f"Listening on port {PORT} (class={SERVER_CLASS}, " + f"instance={INSTANCE_NAME}, release={RELEASE_LEVEL})", + flush=True, + ) + while True: + conn, addr = srv.accept() + t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True) + t.start() + + +if __name__ == "__main__": + main() diff --git a/integration_tests/drda/test.sh b/integration_tests/drda/test.sh new file mode 100755 index 00000000..b6bec33a --- /dev/null +++ b/integration_tests/drda/test.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -e +MODULE_DIR=$(dirname $0) +ZGRAB_ROOT=$(git rev-parse --show-toplevel) +ZGRAB_OUTPUT=$ZGRAB_ROOT/zgrab-output + +mkdir -p $ZGRAB_OUTPUT/drda + +CONTAINER_NAME=zgrab_drda + +OUTPUT_FILE=$ZGRAB_OUTPUT/drda/drda.json + +echo "drda/test: Running drda scan against $CONTAINER_NAME" +CONTAINER_NAME=$CONTAINER_NAME $ZGRAB_ROOT/docker-runner/docker-run.sh drda --port 50000 > $OUTPUT_FILE + +echo "drda/test: BEGIN docker logs from $CONTAINER_NAME [{(" +docker logs --tail all $CONTAINER_NAME +echo ")}] END docker logs from $CONTAINER_NAME" + +# Validate output against the deterministic values baked into server.py. +EXPECTED_SERVER_CLASS="QDB2/NT64" +EXPECTED_INSTANCE_NAME="DB2" +EXPECTED_RELEASE_LEVEL="SQL11013" +EXPECTED_VERSION="11.01.3" + +STATUS=$(jq -r '.data.drda.status' < "$OUTPUT_FILE") +if [ "$STATUS" != "success" ]; then + echo "drda/test: FAIL - expected status 'success', got '$STATUS'" + exit 1 +fi + +SERVER_CLASS=$(jq -r '.data.drda.result.server_class' < "$OUTPUT_FILE") +if [ "$SERVER_CLASS" != "$EXPECTED_SERVER_CLASS" ]; then + echo "drda/test: FAIL - server_class: expected '$EXPECTED_SERVER_CLASS', got '$SERVER_CLASS'" + exit 1 +fi + +INSTANCE_NAME=$(jq -r '.data.drda.result.instance_name' < "$OUTPUT_FILE") +if [ "$INSTANCE_NAME" != "$EXPECTED_INSTANCE_NAME" ]; then + echo "drda/test: FAIL - instance_name: expected '$EXPECTED_INSTANCE_NAME', got '$INSTANCE_NAME'" + exit 1 +fi + +RELEASE_LEVEL=$(jq -r '.data.drda.result.release_level' < "$OUTPUT_FILE") +if [ "$RELEASE_LEVEL" != "$EXPECTED_RELEASE_LEVEL" ]; then + echo "drda/test: FAIL - release_level: expected '$EXPECTED_RELEASE_LEVEL', got '$RELEASE_LEVEL'" + exit 1 +fi + +VERSION=$(jq -r '.data.drda.result.version' < "$OUTPUT_FILE") +if [ "$VERSION" != "$EXPECTED_VERSION" ]; then + echo "drda/test: FAIL - version: expected '$EXPECTED_VERSION', got '$VERSION'" + exit 1 +fi + +echo "drda/test: PASS" diff --git a/modules/drda.go b/modules/drda.go new file mode 100644 index 00000000..6ebfec26 --- /dev/null +++ b/modules/drda.go @@ -0,0 +1,10 @@ +package modules + +import ( + "github.com/zmap/zgrab2" + "github.com/zmap/zgrab2/modules/drda" +) + +func init() { + zgrab2.RegisterModule(drda.NewModule()) +} diff --git a/modules/drda/drda.go b/modules/drda/drda.go new file mode 100644 index 00000000..bc26346b --- /dev/null +++ b/modules/drda/drda.go @@ -0,0 +1,162 @@ +package drda + +import ( + "encoding/binary" + "encoding/hex" + "fmt" +) + +// DRDA (Distributed Relational Database Architecture) is the wire protocol +// spoken by IBM DB2 (and Apache Derby / Informix). A DB2 server responds to an +// EXCSAT ("Exchange Server Attributes") request with an EXCSATRD reply carrying +// server-identifying attributes, all encoded in EBCDIC. + +// DDM code points used by this module. +const ( + cpEXCSAT = 0x1041 // Exchange Server Attributes (request) + cpPRDID = 0x112e // Product ID + cpSRVCLSNM = 0x1147 // Server Class Name (platform, e.g. "QDB2/NT64") + cpSRVRLSLV = 0x115a // Server Product Release Level (e.g. "SQL11013") + cpEXTNAM = 0x115e // External Name + cpSRVNAM = 0x116d // Server Name (instance name, e.g. "DB2") + cpMGRLVLLS = 0x1404 // Manager-Level List + cpEXCSATRD = 0x1443 // Exchange Server Attributes Reply Data + + ddmMagic = 0xD0 // marks the start of every DDM message + ddmHeaderLen = 10 // length(2) magic(1) format(1) corrId(2) length2(2) codePoint(2) +) + +// mgrlvlls is the standard Manager-Level List sent by nmap/Shodan in EXCSAT. +var mgrlvlls = mustHex("1403000724070008240f00081440000814740008") + +// e2a is the EBCDIC (code page 500) -> ASCII translation table used to decode +// the string attributes returned in the EXCSATRD reply. +var e2a = mustHex("000102039C09867F978D8E0B0C0D0E0F" + + "101112139D8508871819928F1C1D1E1F" + + "80818283840A171B88898A8B8C050607" + + "909116939495960498999A9B14159E1A" + + "20A0A1A2A3A4A5A6A7A8D52E3C282B7C" + + "26A9AAABACADAEAFB0B121242A293B5E" + + "2D2FB2B3B4B5B6B7B8B9E52C255F3E3F" + + "BABBBCBDBEBFC0C1C2603A2340273D22" + + "C3616263646566676869C4C5C6C7C8C9" + + "CA6A6B6C6D6E6F707172CBCCCDCECFD0" + + "D17E737475767778797AD2D3D45BD6D7" + + "D8D9DADBDCDDDEDFE0E1E2E3E45DE6E7" + + "7B414243444546474849E8E9EAEBECED" + + "7D4A4B4C4D4E4F505152EEEFF0F1F2F3" + + "5C9F535455565758595AF4F5F6F7F8F9" + + "30313233343536373839FAFBFCFDFEFF") + +func mustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(fmt.Sprintf("drda: invalid hex constant %q: %v", s, err)) + } + return b +} + +// ebcdicToASCII decodes an EBCDIC byte slice into an ASCII string. +func ebcdicToASCII(b []byte) string { + out := make([]byte, len(b)) + for i, c := range b { + out[i] = e2a[c] + } + return string(out) +} + +// buildEXCSAT builds the DRDA EXCSAT probe packet. It mirrors the request sent +// by nmap's drda-info and Shodan: empty EXTNAM/SRVNAM/SRVRLSLV/SRVCLSNM plus the +// standard MGRLVLLS. +func buildEXCSAT() []byte { + var params []byte + appendParam := func(cp int, data []byte) { + p := make([]byte, 4+len(data)) + binary.BigEndian.PutUint16(p[0:2], uint16(4+len(data))) + binary.BigEndian.PutUint16(p[2:4], uint16(cp)) + copy(p[4:], data) + params = append(params, p...) + } + appendParam(cpEXTNAM, nil) + appendParam(cpSRVNAM, nil) + appendParam(cpSRVRLSLV, nil) + appendParam(cpMGRLVLLS, mgrlvlls) + appendParam(cpSRVCLSNM, nil) + + total := ddmHeaderLen + len(params) + ddm := make([]byte, ddmHeaderLen) + binary.BigEndian.PutUint16(ddm[0:2], uint16(total)) // Length + ddm[2] = ddmMagic // Magic (0xD0) + ddm[3] = 0x01 // Format (no CHAINED bit: this is a lone request) + binary.BigEndian.PutUint16(ddm[4:6], 1) // CorrelationID + binary.BigEndian.PutUint16(ddm[6:8], uint16(total-6)) // Length2 + binary.BigEndian.PutUint16(ddm[8:10], cpEXCSAT) // CodePoint + + return append(ddm, params...) +} + +// excsatrd holds the ASCII-decoded attributes parsed out of an EXCSATRD reply. +type excsatrd struct { + externalName string + serverClass string + serverName string + releaseLevel string + productID string +} + +// parseEXCSATRD scans a DRDA response for an EXCSATRD DDM and extracts its +// string attributes, decoding each from EBCDIC to ASCII. Returns false if no +// EXCSATRD DDM is present. +func parseEXCSATRD(data []byte) (*excsatrd, bool) { + // Walk the (possibly chained) top-level DDM messages. + for pos := 0; pos+ddmHeaderLen <= len(data); { + ddmLen := int(binary.BigEndian.Uint16(data[pos : pos+2])) + magic := data[pos+2] + codePoint := int(binary.BigEndian.Uint16(data[pos+8 : pos+10])) + if magic != ddmMagic || ddmLen < ddmHeaderLen || pos+ddmLen > len(data) { + return nil, false + } + if codePoint != cpEXCSATRD { + pos += ddmLen + continue + } + + res := &excsatrd{} + // Parse the nested parameters that fill the rest of this DDM. + p := pos + ddmHeaderLen + end := pos + ddmLen + for p+4 <= end { + paramLen := int(binary.BigEndian.Uint16(data[p : p+2])) + paramCP := int(binary.BigEndian.Uint16(data[p+2 : p+4])) + if paramLen < 4 || p+paramLen > end { + break + } + value := ebcdicToASCII(data[p+4 : p+paramLen]) + switch paramCP { + case cpEXTNAM: + res.externalName = value + case cpSRVCLSNM: + res.serverClass = value + case cpSRVNAM: + res.serverName = value + case cpSRVRLSLV: + res.releaseLevel = value + case cpPRDID: + res.productID = value + } + p += paramLen + } + return res, true + } + return nil, false +} + +// versionFromReleaseLevel converts a DB2 product release level such as +// "SQL11013" into a human-readable version like "11.01.3". Returns "" if the +// input does not match the expected form. +func versionFromReleaseLevel(rel string) string { + if len(rel) < 8 || rel[:3] != "SQL" { + return "" + } + return fmt.Sprintf("%s.%s.%s", rel[3:5], rel[5:7], rel[7:8]) +} diff --git a/modules/drda/drda_test.go b/modules/drda/drda_test.go new file mode 100644 index 00000000..118d5eef --- /dev/null +++ b/modules/drda/drda_test.go @@ -0,0 +1,128 @@ +package drda + +import ( + "encoding/binary" + "net" + "os" + "testing" + "time" +) + +// a2e inverts the e2a table so tests can synthesize EBCDIC-encoded attributes. +func a2e() [256]byte { + var t [256]byte + for e := 0; e < 256; e++ { + t[e2a[e]] = byte(e) + } + return t +} + +func asciiToEBCDIC(s string) []byte { + tbl := a2e() + out := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + out[i] = tbl[s[i]] + } + return out +} + +func makeParam(cp int, data []byte) []byte { + p := make([]byte, 4+len(data)) + binary.BigEndian.PutUint16(p[0:2], uint16(4+len(data))) + binary.BigEndian.PutUint16(p[2:4], uint16(cp)) + copy(p[4:], data) + return p +} + +func makeEXCSATRD(params ...[]byte) []byte { + var body []byte + for _, p := range params { + body = append(body, p...) + } + total := ddmHeaderLen + len(body) + ddm := make([]byte, ddmHeaderLen) + binary.BigEndian.PutUint16(ddm[0:2], uint16(total)) + ddm[2] = ddmMagic + ddm[3] = 0x41 + binary.BigEndian.PutUint16(ddm[4:6], 1) + binary.BigEndian.PutUint16(ddm[6:8], uint16(total-6)) + binary.BigEndian.PutUint16(ddm[8:10], cpEXCSATRD) + return append(ddm, body...) +} + +func TestBuildEXCSAT(t *testing.T) { + got := buildEXCSAT() + // Header must start with total length, DDM magic and EXCSAT codepoint. + if len(got) != int(binary.BigEndian.Uint16(got[0:2])) { + t.Fatalf("length prefix %d != actual length %d", binary.BigEndian.Uint16(got[0:2]), len(got)) + } + if got[2] != ddmMagic { + t.Errorf("magic = 0x%02x, want 0x%02x", got[2], ddmMagic) + } + if cp := binary.BigEndian.Uint16(got[8:10]); cp != cpEXCSAT { + t.Errorf("codepoint = 0x%04x, want 0x%04x", cp, cpEXCSAT) + } +} + +func TestParseEXCSATRD(t *testing.T) { + pkt := makeEXCSATRD( + makeParam(cpEXTNAM, asciiToEBCDIC("DB2 db2sysc 2D9425E0")), + makeParam(cpSRVCLSNM, asciiToEBCDIC("QDB2/NT64")), + makeParam(cpSRVNAM, asciiToEBCDIC("DB2")), + makeParam(cpSRVRLSLV, asciiToEBCDIC("SQL11013")), + ) + attrs, ok := parseEXCSATRD(pkt) + if !ok { + t.Fatal("parseEXCSATRD returned ok=false") + } + if attrs.serverClass != "QDB2/NT64" { + t.Errorf("serverClass = %q, want %q", attrs.serverClass, "QDB2/NT64") + } + if attrs.serverName != "DB2" { + t.Errorf("serverName = %q, want %q", attrs.serverName, "DB2") + } + if attrs.releaseLevel != "SQL11013" { + t.Errorf("releaseLevel = %q, want %q", attrs.releaseLevel, "SQL11013") + } + if attrs.externalName != "DB2 db2sysc 2D9425E0" { + t.Errorf("externalName = %q", attrs.externalName) + } + if v := versionFromReleaseLevel(attrs.releaseLevel); v != "11.01.3" { + t.Errorf("version = %q, want %q", v, "11.01.3") + } +} + +func TestParseEXCSATRD_NotDB2(t *testing.T) { + if _, ok := parseEXCSATRD([]byte("not a drda response at all")); ok { + t.Error("parseEXCSATRD accepted non-DRDA data") + } +} + +// TestLive performs a real scan against a DRDA/DB2 server. Set DRDA_LIVE_TARGET +// to an "ip:port" (e.g. 45.5.105.132:50000) to enable it. +func TestLive(t *testing.T) { + target := os.Getenv("DRDA_LIVE_TARGET") + if target == "" { + t.Skip("set DRDA_LIVE_TARGET=ip:port to run the live test") + } + conn, err := net.DialTimeout("tcp", target, 10*time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(10 * time.Second)) + if _, err = conn.Write(buildEXCSAT()); err != nil { + t.Fatalf("write: %v", err) + } + data, err := readDDM(conn) + if err != nil { + t.Fatalf("read: %v", err) + } + attrs, ok := parseEXCSATRD(data) + if !ok { + t.Fatalf("not an EXCSATRD response: %x", data) + } + t.Logf("serverClass=%q instanceName=%q releaseLevel=%q version=%q externalName=%q productID=%q", + attrs.serverClass, attrs.serverName, attrs.releaseLevel, + versionFromReleaseLevel(attrs.releaseLevel), attrs.externalName, attrs.productID) +} diff --git a/modules/drda/scanner.go b/modules/drda/scanner.go new file mode 100644 index 00000000..3825aeee --- /dev/null +++ b/modules/drda/scanner.go @@ -0,0 +1,138 @@ +// Package drda provides a zgrab2 module that scans for DRDA database servers, +// most commonly IBM DB2 (DRDA is also spoken by Apache Derby and Informix). +// Default port: 50000 (TCP). +// +// It sends a DRDA EXCSAT ("Exchange Server Attributes") request and parses the +// EXCSATRD reply, extracting server-identifying attributes (server class / +// platform, instance name, product release level and external name). These are +// the same attributes surfaced by Shodan and nmap's drda-info script. +package drda + +import ( + "context" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + + log "github.com/sirupsen/logrus" + + "github.com/zmap/zgrab2" +) + +// Flags holds the command-line configuration for the drda scan module. +type Flags struct { + zgrab2.BaseFlags `group:"Basic Options"` +} + +// Results is the output of the drda scan module. +type Results struct { + // ServerClass is the DRDA SRVCLSNM attribute, describing the server + // platform, e.g. "QDB2/NT64". + ServerClass string `json:"server_class,omitempty"` + // InstanceName is the DRDA SRVNAM attribute, e.g. "DB2". + InstanceName string `json:"instance_name,omitempty"` + // ReleaseLevel is the raw DRDA SRVRLSLV attribute, e.g. "SQL11013". + ReleaseLevel string `json:"release_level,omitempty"` + // Version is the human-readable version derived from ReleaseLevel, e.g. + // "11.01.3". + Version string `json:"version,omitempty"` + // ExternalName is the DRDA EXTNAM attribute. + ExternalName string `json:"external_name,omitempty"` + // ProductID is the DRDA PRDID attribute, when present. + ProductID string `json:"product_id,omitempty"` + // Raw is the hex-encoded EXCSATRD response, included when --verbose is set. + Raw string `json:"raw,omitempty"` +} + +// Module implements the zgrab2.Module interface. +func NewModule() *zgrab2.TypedModule[Flags, Scanner, *Scanner] { + return zgrab2.NewTypedModule[Flags, Scanner, *Scanner]( + "drda", + "Probe for DRDA database servers (IBM DB2, Derby, Informix)", + "Send a DRDA EXCSAT request and parse the EXCSATRD reply for server attributes", + 50000, + ) +} + +// Scanner implements the zgrab2.Scanner interface. +type Scanner struct { + zgrab2.BaseScanner + config *Flags +} + +// Validate checks that the flags are valid. Always succeeds. +func (flags Flags) Validate(_ []string) error { + return nil +} + +// Init initializes the Scanner. +func (scanner *Scanner) Init(flags zgrab2.ScanFlags) error { + f, _ := flags.(*Flags) + scanner.config = f + scanner.SetBaseFlags(&f.BaseFlags) + scanner.DialerGroupConfig = &zgrab2.DialerGroupConfig{ + TransportAgnosticDialerProtocol: zgrab2.TransportTCP, + BaseFlags: &f.BaseFlags, + } + return nil +} + +// readDDM reads a single length-prefixed DRDA DDM message from conn. +func readDDM(conn io.Reader) ([]byte, error) { + header := make([]byte, 2) + if _, err := io.ReadFull(conn, header); err != nil { + return nil, fmt.Errorf("could not read DRDA length prefix: %w", err) + } + length := int(binary.BigEndian.Uint16(header)) + if length < ddmHeaderLen { + return nil, fmt.Errorf("invalid DRDA message length %d", length) + } + buf := make([]byte, length) + copy(buf, header) + if _, err := io.ReadFull(conn, buf[2:]); err != nil { + return nil, fmt.Errorf("could not read DRDA message body: %w", err) + } + return buf, nil +} + +// Scan connects to the target (default port 50000), sends a DRDA EXCSAT request, +// and parses the EXCSATRD reply. +func (scanner *Scanner) Scan(ctx context.Context, dialGroup *zgrab2.DialerGroup, target *zgrab2.ScanTarget) (zgrab2.ScanStatus, any, error) { + conn, err := dialGroup.Dial(ctx, target) + if err != nil { + return zgrab2.TryGetScanStatus(err), nil, fmt.Errorf("could not dial target %s: %w", target.String(), err) + } + defer zgrab2.CloseConnAndHandleError(conn) + + if _, err = conn.Write(buildEXCSAT()); err != nil { + return zgrab2.TryGetScanStatus(err), nil, fmt.Errorf("could not send EXCSAT to %s: %w", target.String(), err) + } + + data, err := readDDM(conn) + if err != nil { + return zgrab2.TryGetScanStatus(err), nil, err + } + + attrs, ok := parseEXCSATRD(data) + if !ok { + if scanner.config.Verbose { + log.Debugf("drda: response was not a valid EXCSATRD: %s", hex.EncodeToString(data)) + } + return zgrab2.SCAN_PROTOCOL_ERROR, nil, fmt.Errorf("response from %s was not a DRDA EXCSATRD", target.String()) + } + + results := &Results{ + ServerClass: attrs.serverClass, + InstanceName: attrs.serverName, + ReleaseLevel: attrs.releaseLevel, + Version: versionFromReleaseLevel(attrs.releaseLevel), + ExternalName: attrs.externalName, + ProductID: attrs.productID, + } + if scanner.config.Verbose { + results.Raw = hex.EncodeToString(data) + } + + return zgrab2.SCAN_SUCCESS, results, nil +} diff --git a/zgrab2_schemas/zgrab2/__init__.py b/zgrab2_schemas/zgrab2/__init__.py index f7fed9db..dec128d1 100644 --- a/zgrab2_schemas/zgrab2/__init__.py +++ b/zgrab2_schemas/zgrab2/__init__.py @@ -30,3 +30,4 @@ from . import mqtt from . import pptp from . import checkpoint +from . import drda diff --git a/zgrab2_schemas/zgrab2/drda.py b/zgrab2_schemas/zgrab2/drda.py new file mode 100644 index 00000000..9dd79797 --- /dev/null +++ b/zgrab2_schemas/zgrab2/drda.py @@ -0,0 +1,42 @@ +# zschema sub-schema for zgrab2's drda module +# Registers zgrab2-drda globally, and drda with the main zgrab2 schema. +from zschema.leaves import * +from zschema.compounds import * +import zschema.registry + +from . import zgrab2 + +drda_scan_response = SubRecord( + { + "result": SubRecord( + { + "server_class": String( + doc="The DRDA SRVCLSNM attribute, describing the server platform, e.g. 'QDB2/NT64'." + ), + "instance_name": String( + doc="The DRDA SRVNAM attribute, e.g. the DB2 instance name 'DB2'." + ), + "release_level": String( + doc="The raw DRDA SRVRLSLV product release level attribute, e.g. 'SQL11013'." + ), + "version": String( + doc="The human-readable version derived from release_level, e.g. '11.01.3'." + ), + "external_name": String( + doc="The DRDA EXTNAM external name attribute." + ), + "product_id": String( + doc="The DRDA PRDID product ID attribute, when present." + ), + "raw": String( + doc="The hex-encoded EXCSATRD response, included when --verbose is set." + ), + } + ) + }, + extends=zgrab2.base_scan_response, +) + +zschema.registry.register_schema("zgrab2-drda", drda_scan_response) + +zgrab2.register_scan_response_type("drda", drda_scan_response) From 487b083a8061278b4639474a6dc04f34ec0055bf Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Mon, 6 Jul 2026 13:35:57 -0500 Subject: [PATCH 2/5] Add fuzz test for drda EXCSATRD parser The drda module parses untrusted binary input (binary.BigEndian, io.ReadFull), so scripts/check-fuzz-coverage.sh requires a fuzz test. Add FuzzParseEXCSATRD covering parseEXCSATRD with real, minimal, and adversarial (short buffers, bad magic, lying length prefixes, overrunning parameters) seed inputs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- modules/drda/drda_fuzz_test.go | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 modules/drda/drda_fuzz_test.go diff --git a/modules/drda/drda_fuzz_test.go b/modules/drda/drda_fuzz_test.go new file mode 100644 index 00000000..18881a77 --- /dev/null +++ b/modules/drda/drda_fuzz_test.go @@ -0,0 +1,40 @@ +package drda + +import ( + "encoding/hex" + "testing" +) + +func FuzzParseEXCSATRD(f *testing.F) { + // A well-formed EXCSATRD reply captured from a real IBM DB2 11.1 server. + realReply, _ := hex.DecodeString( + "0066d0030001006014430024115ec4c2f240404040408482f2a2a8a28340f2c4" + + "f9f4c3f2c2f46cc6c5c46ce8f0f0001814041403000724070008240f00081440" + + "000814740008000d1147d8c4c2f261d5e3f6f40007116dc4c2f2000c115ae2d8d3" + + "f1f1f0f1f3") + f.Add(realReply) + + // A minimal, valid EXCSATRD produced by our own builder logic. + f.Add(makeEXCSATRD( + makeParam(cpSRVCLSNM, asciiToEBCDIC("QDB2/NT64")), + makeParam(cpSRVRLSLV, asciiToEBCDIC("SQL11013")), + )) + + // Degenerate / adversarial seeds: too short, bad magic, lying lengths. + f.Add([]byte{}) + f.Add([]byte{0x00, 0x0a}) + f.Add([]byte{0x00, 0x0a, 0xd0, 0x03, 0x00, 0x01, 0x00, 0x04, 0x14, 0x43}) + // Length prefix claims more than the buffer holds. + f.Add([]byte{0xff, 0xff, 0xd0, 0x03, 0x00, 0x01, 0x00, 0x60, 0x14, 0x43}) + // A parameter whose length overruns the DDM. + f.Add([]byte{0x00, 0x0e, 0xd0, 0x03, 0x00, 0x01, 0x00, 0x08, 0x14, 0x43, 0xff, 0xff, 0x11, 0x47}) + + f.Fuzz(func(t *testing.T, data []byte) { + // Must never panic or read out of bounds on arbitrary input. + attrs, ok := parseEXCSATRD(data) + if ok && attrs != nil { + // Exercise the downstream decode path on any parsed attributes. + _ = versionFromReleaseLevel(attrs.releaseLevel) + } + }) +} From a1fdb53266e4174a3ea365137e7346e3953e4a44 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Mon, 6 Jul 2026 13:39:51 -0500 Subject: [PATCH 3/5] Fix lint issues in drda module - golangci-lint (prealloc): preallocate DDM buffer capacity in buildEXCSAT and the test helper so the trailing append does not reallocate. - black: reformat drda.py schema and mock server.py to satisfy the Python formatter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- integration_tests/drda/container/server.py | 6 +++--- modules/drda/drda.go | 2 +- modules/drda/drda_test.go | 2 +- zgrab2_schemas/zgrab2/drda.py | 4 +--- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/integration_tests/drda/container/server.py b/integration_tests/drda/container/server.py index 86d21bd9..80121f05 100644 --- a/integration_tests/drda/container/server.py +++ b/integration_tests/drda/container/server.py @@ -30,9 +30,9 @@ # Deterministic attributes served to the scanner. These mirror what Shodan/nmap # surface for a real IBM DB2 11.1 server, minus any per-connection token. -SERVER_CLASS = "QDB2/NT64" # SRVCLSNM: server platform -INSTANCE_NAME = "DB2" # SRVNAM: instance name -RELEASE_LEVEL = "SQL11013" # SRVRLSLV: -> version 11.01.3 +SERVER_CLASS = "QDB2/NT64" # SRVCLSNM: server platform +INSTANCE_NAME = "DB2" # SRVNAM: instance name +RELEASE_LEVEL = "SQL11013" # SRVRLSLV: -> version 11.01.3 EXTERNAL_NAME = "DB2 db2sysc 00000000%FED%Y00" # EXTNAM # EBCDIC (code page 500) translation table, ASCII index -> EBCDIC byte. This is diff --git a/modules/drda/drda.go b/modules/drda/drda.go index bc26346b..7b9d4081 100644 --- a/modules/drda/drda.go +++ b/modules/drda/drda.go @@ -84,7 +84,7 @@ func buildEXCSAT() []byte { appendParam(cpSRVCLSNM, nil) total := ddmHeaderLen + len(params) - ddm := make([]byte, ddmHeaderLen) + ddm := make([]byte, ddmHeaderLen, total) binary.BigEndian.PutUint16(ddm[0:2], uint16(total)) // Length ddm[2] = ddmMagic // Magic (0xD0) ddm[3] = 0x01 // Format (no CHAINED bit: this is a lone request) diff --git a/modules/drda/drda_test.go b/modules/drda/drda_test.go index 118d5eef..1451f8a1 100644 --- a/modules/drda/drda_test.go +++ b/modules/drda/drda_test.go @@ -40,7 +40,7 @@ func makeEXCSATRD(params ...[]byte) []byte { body = append(body, p...) } total := ddmHeaderLen + len(body) - ddm := make([]byte, ddmHeaderLen) + ddm := make([]byte, ddmHeaderLen, total) binary.BigEndian.PutUint16(ddm[0:2], uint16(total)) ddm[2] = ddmMagic ddm[3] = 0x41 diff --git a/zgrab2_schemas/zgrab2/drda.py b/zgrab2_schemas/zgrab2/drda.py index 9dd79797..ed7a4f51 100644 --- a/zgrab2_schemas/zgrab2/drda.py +++ b/zgrab2_schemas/zgrab2/drda.py @@ -22,9 +22,7 @@ "version": String( doc="The human-readable version derived from release_level, e.g. '11.01.3'." ), - "external_name": String( - doc="The DRDA EXTNAM external name attribute." - ), + "external_name": String(doc="The DRDA EXTNAM external name attribute."), "product_id": String( doc="The DRDA PRDID product ID attribute, when present." ), From f8093a976428375ec1b2341607e4ffc7dcf3e82c Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Mon, 6 Jul 2026 13:48:09 -0500 Subject: [PATCH 4/5] Drop drda integration test in favor of unit tests Remove the mock DRDA server container and docker-compose wiring. The module's unit tests (drda_test.go) and fuzz test already cover the DRDA build/parse logic, matching the convention of other binary-protocol modules (e.g. modbus, oracle, siemens) that ship without an integration test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- integration_tests/drda/container/Dockerfile | 7 - integration_tests/drda/container/server.py | 138 -------------------- integration_tests/drda/test.sh | 57 -------- 3 files changed, 202 deletions(-) delete mode 100644 integration_tests/drda/container/Dockerfile delete mode 100644 integration_tests/drda/container/server.py delete mode 100755 integration_tests/drda/test.sh diff --git a/integration_tests/drda/container/Dockerfile b/integration_tests/drda/container/Dockerfile deleted file mode 100644 index 87a58088..00000000 --- a/integration_tests/drda/container/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM python:3.14.5-slim-bookworm - -COPY server.py /server.py - -EXPOSE 50000 - -ENTRYPOINT ["python3", "/server.py"] diff --git a/integration_tests/drda/container/server.py b/integration_tests/drda/container/server.py deleted file mode 100644 index 80121f05..00000000 --- a/integration_tests/drda/container/server.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env python3 -""" -Minimal DRDA (Distributed Relational Database Architecture) server for -integration testing the zgrab2 drda module. - -DRDA is the wire protocol spoken by IBM DB2 (and Apache Derby / Informix) on -TCP port 50000. This mock answers a DRDA EXCSAT ("Exchange Server Attributes") -request with a fixed EXCSATRD reply carrying deterministic, EBCDIC-encoded -server attributes, mirroring the response of a real IBM DB2 11.1 server. - -Wire layout of a DDM message: - [length:2][magic:1=0xD0][format:1][correlation-id:2][length2:2][codepoint:2] - followed by nested parameters, each: [length:2][codepoint:2][data...] -""" - -import socket -import struct -import threading -import time - -PORT = 50000 - -# DDM / parameter code points. -CP_EXCSAT = 0x1041 -CP_EXCSATRD = 0x1443 -CP_EXTNAM = 0x115E -CP_SRVCLSNM = 0x1147 -CP_SRVNAM = 0x116D -CP_SRVRLSLV = 0x115A - -# Deterministic attributes served to the scanner. These mirror what Shodan/nmap -# surface for a real IBM DB2 11.1 server, minus any per-connection token. -SERVER_CLASS = "QDB2/NT64" # SRVCLSNM: server platform -INSTANCE_NAME = "DB2" # SRVNAM: instance name -RELEASE_LEVEL = "SQL11013" # SRVRLSLV: -> version 11.01.3 -EXTERNAL_NAME = "DB2 db2sysc 00000000%FED%Y00" # EXTNAM - -# EBCDIC (code page 500) translation table, ASCII index -> EBCDIC byte. This is -# the inverse of the e2a table used by the zgrab2 drda module. -E2A_HEX = ( - "000102039C09867F978D8E0B0C0D0E0F101112139D8508871819928F1C1D1E1F" - "80818283840A171B88898A8B8C050607909116939495960498999A9B14159E1A" - "20A0A1A2A3A4A5A6A7A8D52E3C282B7C26A9AAABACADAEAFB0B121242A293B5E" - "2D2FB2B3B4B5B6B7B8B9E52C255F3E3FBABBBCBDBEBFC0C1C2603A2340273D22" - "C3616263646566676869C4C5C6C7C8C9CA6A6B6C6D6E6F707172CBCCCDCECFD0" - "D17E737475767778797AD2D3D45BD6D7D8D9DADBDCDDDEDFE0E1E2E3E45DE6E7" - "7B414243444546474849E8E9EAEBECED7D4A4B4C4D4E4F505152EEEFF0F1F2F3" - "5C9F535455565758595AF4F5F6F7F8F930313233343536373839FAFBFCFDFEFF" -) -_e2a = bytes.fromhex(E2A_HEX) -_a2e = bytearray(256) -for _e in range(256): - _a2e[_e2a[_e]] = _e - - -def to_ebcdic(s: str) -> bytes: - return bytes(_a2e[ord(c)] for c in s) - - -def make_param(codepoint: int, data: bytes) -> bytes: - return struct.pack(">HH", len(data) + 4, codepoint) + data - - -def build_excsatrd() -> bytes: - params = ( - make_param(CP_EXTNAM, to_ebcdic(EXTERNAL_NAME)) - + make_param(CP_SRVCLSNM, to_ebcdic(SERVER_CLASS)) - + make_param(CP_SRVNAM, to_ebcdic(INSTANCE_NAME)) - + make_param(CP_SRVRLSLV, to_ebcdic(RELEASE_LEVEL)) - ) - total = 10 + len(params) - # length, magic(0xD0), format, correlation-id, length2, codepoint - ddm = struct.pack(">HBBHHH", total, 0xD0, 0x03, 1, total - 6, CP_EXCSATRD) - return ddm + params - - -EXCSATRD = build_excsatrd() - - -def read_ddm(conn: socket.socket) -> bytes: - header = b"" - while len(header) < 2: - chunk = conn.recv(2 - len(header)) - if not chunk: - return b"" - header += chunk - length = struct.unpack(">H", header)[0] - body = header - while len(body) < length: - chunk = conn.recv(length - len(body)) - if not chunk: - return b"" - body += chunk - return body - - -def handle_client(conn: socket.socket, addr): - print(f"Connection from {addr}", flush=True) - try: - req = read_ddm(conn) - if len(req) < 10: - print(f"Short request from {addr}: {req.hex()}", flush=True) - return - codepoint = struct.unpack(">H", req[8:10])[0] - if codepoint != CP_EXCSAT: - print( - f"Unexpected request codepoint 0x{codepoint:04x} from {addr}", - flush=True, - ) - return - conn.sendall(EXCSATRD) - print(f"Served EXCSATRD to {addr}", flush=True) - # Let the scanner finish reading before we close. - time.sleep(1) - except Exception as e: - print(f"Error handling {addr}: {e}", flush=True) - finally: - conn.close() - - -def main(): - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv: - srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - srv.bind(("0.0.0.0", PORT)) - srv.listen(16) - print( - f"Listening on port {PORT} (class={SERVER_CLASS}, " - f"instance={INSTANCE_NAME}, release={RELEASE_LEVEL})", - flush=True, - ) - while True: - conn, addr = srv.accept() - t = threading.Thread(target=handle_client, args=(conn, addr), daemon=True) - t.start() - - -if __name__ == "__main__": - main() diff --git a/integration_tests/drda/test.sh b/integration_tests/drda/test.sh deleted file mode 100755 index b6bec33a..00000000 --- a/integration_tests/drda/test.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash - -set -e -MODULE_DIR=$(dirname $0) -ZGRAB_ROOT=$(git rev-parse --show-toplevel) -ZGRAB_OUTPUT=$ZGRAB_ROOT/zgrab-output - -mkdir -p $ZGRAB_OUTPUT/drda - -CONTAINER_NAME=zgrab_drda - -OUTPUT_FILE=$ZGRAB_OUTPUT/drda/drda.json - -echo "drda/test: Running drda scan against $CONTAINER_NAME" -CONTAINER_NAME=$CONTAINER_NAME $ZGRAB_ROOT/docker-runner/docker-run.sh drda --port 50000 > $OUTPUT_FILE - -echo "drda/test: BEGIN docker logs from $CONTAINER_NAME [{(" -docker logs --tail all $CONTAINER_NAME -echo ")}] END docker logs from $CONTAINER_NAME" - -# Validate output against the deterministic values baked into server.py. -EXPECTED_SERVER_CLASS="QDB2/NT64" -EXPECTED_INSTANCE_NAME="DB2" -EXPECTED_RELEASE_LEVEL="SQL11013" -EXPECTED_VERSION="11.01.3" - -STATUS=$(jq -r '.data.drda.status' < "$OUTPUT_FILE") -if [ "$STATUS" != "success" ]; then - echo "drda/test: FAIL - expected status 'success', got '$STATUS'" - exit 1 -fi - -SERVER_CLASS=$(jq -r '.data.drda.result.server_class' < "$OUTPUT_FILE") -if [ "$SERVER_CLASS" != "$EXPECTED_SERVER_CLASS" ]; then - echo "drda/test: FAIL - server_class: expected '$EXPECTED_SERVER_CLASS', got '$SERVER_CLASS'" - exit 1 -fi - -INSTANCE_NAME=$(jq -r '.data.drda.result.instance_name' < "$OUTPUT_FILE") -if [ "$INSTANCE_NAME" != "$EXPECTED_INSTANCE_NAME" ]; then - echo "drda/test: FAIL - instance_name: expected '$EXPECTED_INSTANCE_NAME', got '$INSTANCE_NAME'" - exit 1 -fi - -RELEASE_LEVEL=$(jq -r '.data.drda.result.release_level' < "$OUTPUT_FILE") -if [ "$RELEASE_LEVEL" != "$EXPECTED_RELEASE_LEVEL" ]; then - echo "drda/test: FAIL - release_level: expected '$EXPECTED_RELEASE_LEVEL', got '$RELEASE_LEVEL'" - exit 1 -fi - -VERSION=$(jq -r '.data.drda.result.version' < "$OUTPUT_FILE") -if [ "$VERSION" != "$EXPECTED_VERSION" ]; then - echo "drda/test: FAIL - version: expected '$EXPECTED_VERSION', got '$VERSION'" - exit 1 -fi - -echo "drda/test: PASS" From d9a39cdb7e4ee7be482a3c50c7212ad6e1084a03 Mon Sep 17 00:00:00 2001 From: Sean Smith Date: Mon, 6 Jul 2026 13:48:47 -0500 Subject: [PATCH 5/5] Revert docker-compose drda service and network Complete the integration-test removal: drop the drda service block and drda-network that referenced the now-deleted ./drda/container context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- integration_tests/docker-compose.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/integration_tests/docker-compose.yml b/integration_tests/docker-compose.yml index d8689c7a..654a8a85 100644 --- a/integration_tests/docker-compose.yml +++ b/integration_tests/docker-compose.yml @@ -74,14 +74,6 @@ services: CHECKPOINT_HOST: "fw1.example.com" CHECKPOINT_DOMAIN: "example.com" - drda: - build: - context: ./drda/container - container_name: "zgrab_drda" - networks: - - drda-network - hostname: "target" - ftp: build: context: ./ftp/container @@ -796,8 +788,3 @@ networks: ipam: config: - subnet: 100.64.0.192/30 - drda-network: - driver: bridge - ipam: - config: - - subnet: 100.64.0.200/30