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..7b9d4081 --- /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, 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) + 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_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) + } + }) +} diff --git a/modules/drda/drda_test.go b/modules/drda/drda_test.go new file mode 100644 index 00000000..1451f8a1 --- /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, total) + 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..ed7a4f51 --- /dev/null +++ b/zgrab2_schemas/zgrab2/drda.py @@ -0,0 +1,40 @@ +# 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)