zgrab2/modules/postgres/scanner.go

444 lines
14 KiB
Go
Raw Normal View History

Implements postgres zgrab2 module (#30) * remove unnecessary indirection on net.Conn * Ignore *.pyc * fix NPE on nil handshake * refactoring -- move status to status.go; add Open() methods for ScanTarget * cherry-pick .gitignore fix * pull in TLS fix * status.go comments * trim over-generalizations * use /usr/bin/env bash instead of absolute path * remove debug tcpwrap * add integration tests for postgres * hack for cleanup.sh to work on mingw -- use //var/lib instead of /var/lib * cleanup should actually stop the process though * comments / rearrange * Bump up timeout in postgres tests; only pass user if explicitly requested to do so * add schema stubs to new.sh * Integration test fixes -- use /usr/bin/env bash; log all validation failures * add postgres schemas * fill out zcrypto.client_hello schema * handle early get of TLSLog * postgres: return SCAN_SUCCESS on success * cleanup * fix new.sh * fix typo * postgres container cleanup * build.sh docs * standardize container/image names * add not to check for success * shift mysql's connection management to ScanTarget.Open(); wrap Read/Write methods returned by ScanTarget.Open() to enforce timeouts * catch schematically-valid but non-successful scans * postgres: clean up output format; more scanning * cleanup; better error handling; get detailed protocol version error * refactor modules * clean up dangling connections * split gigantic postgres.go * remove unused * ServerParams gets its own type * refactor integration tests: run zgrab2 in its own container, which is linked to the service containers, so that we don't need to keep track of unique ports on the host any more * rename entrypoint; remove duplicate postgres tests * comments for postgres schema * Use param expansion to check for env variable [minor] This is a *very* minor change to `docker-runner/docker-run.sh` checks to see if the environment variable required to run the script has been set to a non-empty string. If not, the script exits with a non-zero status code and displays a default message: ``` ❯ docker-runner/docker-run.sh docker-runner/docker-run.sh: line 7: CONTAINER_NAME: parameter null or not set ``` This was the behavior before, but just uses a one-liner declarative bash idiom. For further reading on parameter expansion, see https://stackoverflow.com/a/307735. @justinbastress can tell me if I did something wrong and broke the intent of the script :-) * Add integration_test targets to makefile; use makefile instead of directly calling go build everywhere; run postgres schema through PEP8 linter * use make in docker-runner entrypoint * add .integration_test_setup to .gitignore * more .gitignore items * Makefile updates: Windows support; add docker-runner target; better cleanup. * docker-runner Dockerfile: start from zgrab2_runner_base image * cleanup postgres setup * make travis use make * add .gitattributes, try to prevent it from overriding lfs with crlfs in shell scripts at least * fix folder name in Makefile * update go (one of our dependencies now works only with >= 1.9) * From travis: `I don't have any idea what to do with '1.9.0'.` * explicit clean make * fix dep order * fix build.sh location * popd * use make to ensure zgrab2_runner exists * Make docker-runner an order-dependency for integration-test-cleanup; don't do a cleanup after each integration test * use explicit tag name for zgrab2_runner * Add container-clean target to Makefile, to remove cyclic dependency on docker; use .id files to track docker images; add servce-base image; use Make to build / track images * use LF in Makefiles; update .gitignore; use zgrab_service_base image in ssh container; fix line endings (?) * remove overzealous cleanup * let setup continue even if some containers are already running * zgrab depends on *.go * docker-runner depends on zgrab2 binary * clean output before running integration tests
2018-01-15 19:24:57 +00:00
package postgres
import (
"encoding/binary"
"fmt"
"net"
"strings"
log "github.com/sirupsen/logrus"
"github.com/zmap/zgrab2"
)
const (
// From https://www.postgresql.org/docs/10/static/protocol-message-formats.html: "The SSL request code. The value is chosen to contain 1234 in the most significant 16 bits, and 5679 in the least significant 16 bits. (To avoid confusion, this code must not be the same as any protocol version number.)"
postgresSSLRequest = 80877103
)
const (
KeyUnknownErrorTag = "_unknown_error_tag"
KeyBadParameters = "_bad_parameters"
)
// PostgresResults is the information returned by the scanner to the caller.
// https://raw.githubusercontent.com/nmap/nmap/master/nmap-service-probes uses the line number of the error response (e.g. StartupError["line"]) to infer the version number
type PostgresResults struct {
TLSLog *zgrab2.TLSLog `json:"tls,omitempty"`
SupportedVersions string `json:"supported_versions,omitempty"`
ProtocolError *PostgresError `json:"protocol_error,omitempty"`
StartupError *PostgresError `json:"startup_error,omitempty"`
UserStartupError *PostgresError `json:"user_startup_error,omitempty"`
IsSSL bool `json:"is_ssl"`
AuthenticationMode *AuthenticationMode `json:"authentication_mode,omitempty"`
ServerParameters *ServerParameters `json:"server_parameters,omitempty"`
BackendKeyData *BackendKeyData `json:"backend_key_data,omitempty", zgrab:"debug"`
TransactionStatus string `json:"transaction_status,omitempty"`
}
// PostgresError is parsed the payload of an 'E'-type packet, mapping the friendly names of the various fields to the values returned by the server
type PostgresError map[string]string
// After authentication, the server sends a series of 'S' packets with key/value pairs.
// We keep track of them all -- but the golang postgres library only stores the server_version and TimeZone.
type ServerParameters map[string]string
// BackendKeyData is the data returned by the 'K'-type packet
type BackendKeyData struct {
ProcessID uint32 `json:"process_id"`
SecretKey uint32 `json:"secret_key"`
}
// AuthenticationMode abstracts the various 'R'-type packets
type AuthenticationMode struct {
Mode string `json:"mode"`
Payload []byte `json:"payload,omitempty"'`
}
// PostgresFlags sets the module-specific flags that can be passed in from the command line
type PostgresFlags struct {
zgrab2.BaseFlags
zgrab2.TLSFlags
SkipSSL bool `long:"skip-ssl" description:"If set, do not attempt to negotiate an SSL connection"`
Verbose bool `long:"verbose" description:"More verbose logging, include debug fields in the scan results"`
ProtocolVersion string `long:"protocol-version" description:"The protocol to use in the StartupPacket" default:"3.0"`
Implements postgres zgrab2 module (#30) * remove unnecessary indirection on net.Conn * Ignore *.pyc * fix NPE on nil handshake * refactoring -- move status to status.go; add Open() methods for ScanTarget * cherry-pick .gitignore fix * pull in TLS fix * status.go comments * trim over-generalizations * use /usr/bin/env bash instead of absolute path * remove debug tcpwrap * add integration tests for postgres * hack for cleanup.sh to work on mingw -- use //var/lib instead of /var/lib * cleanup should actually stop the process though * comments / rearrange * Bump up timeout in postgres tests; only pass user if explicitly requested to do so * add schema stubs to new.sh * Integration test fixes -- use /usr/bin/env bash; log all validation failures * add postgres schemas * fill out zcrypto.client_hello schema * handle early get of TLSLog * postgres: return SCAN_SUCCESS on success * cleanup * fix new.sh * fix typo * postgres container cleanup * build.sh docs * standardize container/image names * add not to check for success * shift mysql's connection management to ScanTarget.Open(); wrap Read/Write methods returned by ScanTarget.Open() to enforce timeouts * catch schematically-valid but non-successful scans * postgres: clean up output format; more scanning * cleanup; better error handling; get detailed protocol version error * refactor modules * clean up dangling connections * split gigantic postgres.go * remove unused * ServerParams gets its own type * refactor integration tests: run zgrab2 in its own container, which is linked to the service containers, so that we don't need to keep track of unique ports on the host any more * rename entrypoint; remove duplicate postgres tests * comments for postgres schema * Use param expansion to check for env variable [minor] This is a *very* minor change to `docker-runner/docker-run.sh` checks to see if the environment variable required to run the script has been set to a non-empty string. If not, the script exits with a non-zero status code and displays a default message: ``` ❯ docker-runner/docker-run.sh docker-runner/docker-run.sh: line 7: CONTAINER_NAME: parameter null or not set ``` This was the behavior before, but just uses a one-liner declarative bash idiom. For further reading on parameter expansion, see https://stackoverflow.com/a/307735. @justinbastress can tell me if I did something wrong and broke the intent of the script :-) * Add integration_test targets to makefile; use makefile instead of directly calling go build everywhere; run postgres schema through PEP8 linter * use make in docker-runner entrypoint * add .integration_test_setup to .gitignore * more .gitignore items * Makefile updates: Windows support; add docker-runner target; better cleanup. * docker-runner Dockerfile: start from zgrab2_runner_base image * cleanup postgres setup * make travis use make * add .gitattributes, try to prevent it from overriding lfs with crlfs in shell scripts at least * fix folder name in Makefile * update go (one of our dependencies now works only with >= 1.9) * From travis: `I don't have any idea what to do with '1.9.0'.` * explicit clean make * fix dep order * fix build.sh location * popd * use make to ensure zgrab2_runner exists * Make docker-runner an order-dependency for integration-test-cleanup; don't do a cleanup after each integration test * use explicit tag name for zgrab2_runner * Add container-clean target to Makefile, to remove cyclic dependency on docker; use .id files to track docker images; add servce-base image; use Make to build / track images * use LF in Makefiles; update .gitignore; use zgrab_service_base image in ssh container; fix line endings (?) * remove overzealous cleanup * let setup continue even if some containers are already running * zgrab depends on *.go * docker-runner depends on zgrab2 binary * clean output before running integration tests
2018-01-15 19:24:57 +00:00
User string `long:"user" description:"Username to pass to StartupMessage. If omitted, no user will be sent." default:""`
Database string `long:"database" description:"Database to pass to StartupMessage. If omitted, none will be sent." default:""`
ApplicationName string `long:"application-name" description:"application_name value to pass in StartupMessage. If omitted, none will be sent." default:""`
Implements postgres zgrab2 module (#30) * remove unnecessary indirection on net.Conn * Ignore *.pyc * fix NPE on nil handshake * refactoring -- move status to status.go; add Open() methods for ScanTarget * cherry-pick .gitignore fix * pull in TLS fix * status.go comments * trim over-generalizations * use /usr/bin/env bash instead of absolute path * remove debug tcpwrap * add integration tests for postgres * hack for cleanup.sh to work on mingw -- use //var/lib instead of /var/lib * cleanup should actually stop the process though * comments / rearrange * Bump up timeout in postgres tests; only pass user if explicitly requested to do so * add schema stubs to new.sh * Integration test fixes -- use /usr/bin/env bash; log all validation failures * add postgres schemas * fill out zcrypto.client_hello schema * handle early get of TLSLog * postgres: return SCAN_SUCCESS on success * cleanup * fix new.sh * fix typo * postgres container cleanup * build.sh docs * standardize container/image names * add not to check for success * shift mysql's connection management to ScanTarget.Open(); wrap Read/Write methods returned by ScanTarget.Open() to enforce timeouts * catch schematically-valid but non-successful scans * postgres: clean up output format; more scanning * cleanup; better error handling; get detailed protocol version error * refactor modules * clean up dangling connections * split gigantic postgres.go * remove unused * ServerParams gets its own type * refactor integration tests: run zgrab2 in its own container, which is linked to the service containers, so that we don't need to keep track of unique ports on the host any more * rename entrypoint; remove duplicate postgres tests * comments for postgres schema * Use param expansion to check for env variable [minor] This is a *very* minor change to `docker-runner/docker-run.sh` checks to see if the environment variable required to run the script has been set to a non-empty string. If not, the script exits with a non-zero status code and displays a default message: ``` ❯ docker-runner/docker-run.sh docker-runner/docker-run.sh: line 7: CONTAINER_NAME: parameter null or not set ``` This was the behavior before, but just uses a one-liner declarative bash idiom. For further reading on parameter expansion, see https://stackoverflow.com/a/307735. @justinbastress can tell me if I did something wrong and broke the intent of the script :-) * Add integration_test targets to makefile; use makefile instead of directly calling go build everywhere; run postgres schema through PEP8 linter * use make in docker-runner entrypoint * add .integration_test_setup to .gitignore * more .gitignore items * Makefile updates: Windows support; add docker-runner target; better cleanup. * docker-runner Dockerfile: start from zgrab2_runner_base image * cleanup postgres setup * make travis use make * add .gitattributes, try to prevent it from overriding lfs with crlfs in shell scripts at least * fix folder name in Makefile * update go (one of our dependencies now works only with >= 1.9) * From travis: `I don't have any idea what to do with '1.9.0'.` * explicit clean make * fix dep order * fix build.sh location * popd * use make to ensure zgrab2_runner exists * Make docker-runner an order-dependency for integration-test-cleanup; don't do a cleanup after each integration test * use explicit tag name for zgrab2_runner * Add container-clean target to Makefile, to remove cyclic dependency on docker; use .id files to track docker images; add servce-base image; use Make to build / track images * use LF in Makefiles; update .gitignore; use zgrab_service_base image in ssh container; fix line endings (?) * remove overzealous cleanup * let setup continue even if some containers are already running * zgrab depends on *.go * docker-runner depends on zgrab2 binary * clean output before running integration tests
2018-01-15 19:24:57 +00:00
}
// PostgresScanner is the zgrab2 scanner type for the postgres protocol
type PostgresScanner struct {
Config *PostgresFlags
}
// PostgresModule is the zgrab2 module for the postgres protocol
type PostgresModule struct {
}
// decodeAuthMode() decodes the body of an 'R'-type packet and returns a friendlier description of it
func decodeAuthMode(buf []byte) *AuthenticationMode {
// See the 'R' messages in https://www.postgresql.org/docs/10/static/protocol-message-formats.html
modeMap := map[uint32]string{
2: "kerberos_v5",
3: "password_cleartext",
5: "password_md5",
6: "scm_credentials",
7: "gss",
9: "sspi",
10: "sasl",
// The following aren't actually authentication codes, but they are valid 'R'-type messages
0: "ok",
8: "gss-continue",
11: "sasl-continue",
12: "sasl-final",
}
modeId := binary.BigEndian.Uint32(buf[0:4])
mode, ok := modeMap[modeId]
if !ok {
mode = fmt.Sprintf("unknown (0x%x)", modeId)
}
return &AuthenticationMode{
Mode: mode,
Payload: buf[4:],
}
}
// decodeError() decodes an 'E'-type tag into a map of friendly name -> value; see https://www.postgresql.org/docs/10/static/protocol-error-fields.html
func decodeError(buf []byte) *PostgresError {
partMap := map[byte]string{
'S': "severity",
// Return both severity and severity_v -- they give the same content, but severity is localized, so it can leak some information about the server
'V': "severity_v",
'C': "code",
'M': "message",
'D': "detail",
'H': "hint",
'P': "position",
'p': "internal_position",
'q': "internal_query",
'W': "where",
's': "schema",
't': "table",
'd': "data",
'n': "constraint",
'F': "file",
'L': "line",
'R': "routine",
}
ret := make(PostgresError)
parts := strings.Split(string(buf), "\x00")
for _, part := range parts {
if len(part) > 0 {
key, ok := partMap[part[0]]
if !ok {
ret[KeyUnknownErrorTag] = appendStringList(ret[KeyUnknownErrorTag], part)
} else {
value := part[1:]
ret[key] = value
}
}
}
return &ret
}
// appendStringList() adds an entry to a semicolon-separated list; if the list is empty, no semicolon is added.
func appendStringList(dest string, val string) string {
if dest == "" {
return val
} else {
return dest + "; " + val
}
}
// ServerParameters.appendBadParam() adds a packet to the list of bad/unexpected parameters
func (p *ServerParameters) appendBadParam(packet *ServerPacket) {
(*p)[KeyBadParameters] = appendStringList((*p)[KeyBadParameters], packet.ToString())
}
// PostgresResults.decodeServerResponse() fills out the results object with packets returned by the server.
func (results *PostgresResults) decodeServerResponse(packets []*ServerPacket) {
// Note: The only parameters the golang postgres library pays attention to are the server_version and the TimeZone.
serverParams := make(ServerParameters)
for _, packet := range packets {
switch packet.Type {
case 'S':
parts := strings.Split(string(packet.Body), "\x00")
if len(parts) == 2 || (len(parts) == 3 && len(parts[2]) == 0) {
serverParams[parts[0]] = parts[1]
} else {
log.Debugf("Unexpected format for ParameterStatus packet (%d parts)", len(parts))
serverParams.appendBadParam(packet)
}
case 'K':
if packet.Length != 12 {
log.Debugf("Bad size for BackendKeyData (%d)", packet.Length)
serverParams.appendBadParam(packet)
} else {
pid := binary.BigEndian.Uint32(packet.Body[0:4])
key := binary.BigEndian.Uint32(packet.Body[4:8])
results.BackendKeyData = &BackendKeyData{
ProcessID: pid,
SecretKey: key,
}
}
case 'Z':
if packet.Length != 5 {
log.Debugf("Bad size for ReadyForQuery (%d)", packet.Length)
serverParams.appendBadParam(packet)
} else {
results.TransactionStatus = string(packet.Body[0])
}
case 'R':
results.AuthenticationMode = decodeAuthMode(packet.Body)
case 'E':
results.UserStartupError = decodeError(packet.Body)
default:
// Ignore other message types
}
}
// Merge the ServerParams, so that we can keep track of values across multiple connections
if len(serverParams) > 0 {
if results.ServerParameters == nil {
results.ServerParameters = &serverParams
} else {
for k, v := range serverParams {
(*results.ServerParameters)[k] = v
}
}
}
}
func (m *PostgresModule) NewFlags() interface{} {
return new(PostgresFlags)
}
func (m *PostgresModule) NewScanner() zgrab2.Scanner {
return new(PostgresScanner)
}
func (f *PostgresFlags) Validate(args []string) error {
return nil
}
func (f *PostgresFlags) Help() string {
return ""
}
func (s *PostgresScanner) Init(flags zgrab2.ScanFlags) error {
f, _ := flags.(*PostgresFlags)
s.Config = f
return nil
}
func (s *PostgresScanner) InitPerSender(senderID int) error {
return nil
}
func (s *PostgresScanner) GetName() string {
return s.Config.Name
}
func (s *PostgresScanner) GetPort() uint {
return s.Config.Port
}
// PostgresScanner.DoSSL() attempts to upgrade the connection to SSL, returning an error on failure.
func (s *PostgresScanner) DoSSL(sql *Connection) error {
var conn *zgrab2.TLSConnection
var err error
if conn, err = s.Config.TLSFlags.GetTLSConnection(sql.Connection); err != nil {
return err
}
if err = conn.Handshake(); err != nil {
return err
}
// Replace sql.Connection to allow future calls to go over the secure connection
sql.Connection = conn
return nil
}
// PostgresScanner.newConnection() opens up a new connection to the ScanTarget, and if necessary, attempts to update the connection to SSL
func (s *PostgresScanner) newConnection(t *zgrab2.ScanTarget, mgr *connectionManager, nossl bool) (*Connection, *zgrab2.ScanError) {
var conn net.Conn
var err error
// Open a managed connection to the ScanTarget, register it for automatic cleanup
if conn, err = t.Open(&s.Config.BaseFlags); err != nil {
return nil, zgrab2.DetectScanError(err)
}
mgr.addConnection(conn)
sql := Connection{Connection: conn, Config: s.Config}
sql.IsSSL = false
if !nossl && !s.Config.SkipSSL {
hasSSL, sslError := sql.RequestSSL()
if sslError != nil {
return nil, sslError
}
if hasSSL {
if err = s.DoSSL(&sql); err != nil {
return nil, zgrab2.NewScanError(zgrab2.SCAN_APPLICATION_ERROR, err)
}
sql.IsSSL = true
}
}
return &sql, nil
}
// Return the default KVPs used for all Startup messages
func (s *PostgresScanner) getDefaultKVPs() map[string]string {
return map[string]string{
"client_encoding": "UTF8",
"datestyle": "ISO, MDY",
}
}
// PostgresScanner.Scan() does the actual scanning. It opens two connections:
// With the first it sends a bogus protocol version in hopes of getting a list of supported protcols back.
// With the second, it sends a standard StartupMessage, but without the required "user" field.
func (s *PostgresScanner) Scan(t zgrab2.ScanTarget) (status zgrab2.ScanStatus, result interface{}, thrown error) {
var results PostgresResults
mgr := newConnectionManager()
defer mgr.cleanUp()
// Send too-low protocol version (0.0) StartupMessage to get a simple supported-protocols error string
// Also do TLS handshake, if configured / supported
{
sql, connectErr := s.newConnection(&t, mgr, false)
if connectErr != nil {
return connectErr.Unpack(nil)
}
defer sql.Close()
if sql.IsSSL {
results.IsSSL = true
// This pointer will be populated as the connection is negotiated
results.TLSLog = sql.GetTLSLog()
} else {
results.IsSSL = false
results.TLSLog = nil
}
// Do SSL the first round, so that if we bail, we still have the TLS logs
// Announce a (bogus) version 0.0 client, expect an 'E'-tagged response with just the error message
if err := sql.SendU32(0x00); err != nil {
return zgrab2.TryGetScanStatus(err), &results, err
}
response, readErr := sql.ReadPacket()
if readErr != nil {
return readErr.Unpack(&results)
}
if response.Type != 'E' {
// No server should be allowing a 0.0 client...but if it does allow it, don't bail out
log.Debugf("Unexpected response from server: %s", response.ToString())
results.SupportedVersions = response.ToString()
} else {
results.SupportedVersions = strings.Trim(string(response.Body), "\x00\r\n ")
}
if _, err := sql.ReadAll(); err != nil {
return err.Unpack(&results)
}
sql.Close()
}
// Send too-high protocol version (255.255) StartupMessage to get full error message (including line numbers, useful for probing server version)
{
sql, connectErr := s.newConnection(&t, mgr, true)
if connectErr != nil {
return connectErr.Unpack(&results)
}
if err := sql.SendU32(0xff<<16 | 0xff); err != nil {
// Whatever the actual problem, a send error will be treated as a SCAN_PROTOCOL_ERROR since the scan got this far
return zgrab2.SCAN_PROTOCOL_ERROR, &results, err
}
response, readErr := sql.ReadPacket()
if readErr != nil {
return readErr.Unpack(&results)
}
if response.Type != 'E' {
// No server should be allowing a 255.255 client...but if it does allow it, don't bail out
log.Debugf("Unexpected response from server: %s", response.ToString())
results.ProtocolError = nil
} else {
results.ProtocolError = decodeError(response.Body)
}
if _, err := sql.ReadAll(); err != nil {
return err.Unpack(&results)
}
sql.Close()
}
// Send a StartupMessage with a valid protocol version number, but omit the user field
{
sql, connectErr := s.newConnection(&t, mgr, true)
if connectErr != nil {
return connectErr.Unpack(&results)
}
if err := sql.SendStartupMessage(s.Config.ProtocolVersion, s.getDefaultKVPs()); err != nil {
return zgrab2.SCAN_PROTOCOL_ERROR, &results, err
}
if response, err := sql.ReadPacket(); err != nil {
log.Debugf("Error reading response after StartupMessage: %v", err)
return err.Unpack(&results)
} else {
if response.Type == 'E' {
results.StartupError = decodeError(response.Body)
} else {
// No server should allow a missing User field -- but if it does, log and continue
log.Debugf("Unexpected response from server: %s", response.ToString())
}
}
// TODO: use any packets returned to fill out results? There probably won't be any, and they will probably be overwritten if Config.User etc is set...
if _, err := sql.ReadAll(); err != nil {
return err.Unpack(&results)
}
sql.Close()
}
// If user / database / application_name are provided, do a final scan with those
if s.Config.User != "" || s.Config.Database != "" || s.Config.ApplicationName != "" {
sql, connectErr := s.newConnection(&t, mgr, false)
if connectErr != nil {
return connectErr.Unpack(&results)
}
kvps := s.getDefaultKVPs()
if s.Config.User != "" {
kvps["user"] = s.Config.User
}
if s.Config.Database != "" {
kvps["database"] = s.Config.Database
}
if s.Config.ApplicationName != "" {
kvps["application_name"] = s.Config.ApplicationName
}
if err := sql.SendStartupMessage(s.Config.ProtocolVersion, kvps); err != nil {
return zgrab2.SCAN_PROTOCOL_ERROR, &results, err
}
packets, err := sql.ReadAll()
sql.Close()
if packets != nil {
results.decodeServerResponse(packets)
}
if err != nil {
return err.Unpack(&results)
}
}
return zgrab2.SCAN_SUCCESS, &results, thrown
}
// Called by modules/postgres.go's init()
func RegisterModule() {
var module PostgresModule
_, err := zgrab2.AddCommand("postgres", "Postgres", "Grab a Postgres handshake", 5432, &module)
log.SetLevel(log.DebugLevel)
if err != nil {
log.Fatal(err)
}
}