zgrab2/modules/http/scanner.go
AG ecd32d0d5d
Feature fail http to https for known status code and substrings (#308)
* Add `--fail-http-to-https` to retry as HTTPS when --retry-https is used and certain known responses are encountered indicating HTTPS should be used

* Apache: HTTP/400, substring "You're speaking plain HTTP to an SSL-enabled server port"
* NGINX: HTTP/400, substring "The plain HTTP request was sent to HTTPS port"

* Add additional substring observed indicating http->https

* Add another observed substring, adjust whitespace

* Use go-ism for slicing implicitly from index 0

Co-authored-by: Adam Greene <copyright@mzpqnxow.com>
2021-06-06 11:31:36 -07:00

619 lines
21 KiB
Go

// Package http contains the zgrab2 Module implementation for HTTP(S).
//
// The Flags can be configured to perform a specific Method (e.g. "GET") on the
// specified Path (e.g. "/"). If UseHTTPS is true, the scanner uses TLS for the
// initial request. The Result contains the final HTTP response following each
// response in the redirect chain.
package http
import (
"bytes"
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/csv"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/url"
"strconv"
"strings"
"time"
log "github.com/sirupsen/logrus"
"github.com/zmap/zcrypto/tls"
"github.com/zmap/zgrab2"
"github.com/zmap/zgrab2/lib/http"
"golang.org/x/net/html/charset"
)
var (
// ErrRedirLocalhost is returned when an HTTP redirect points to localhost,
// unless FollowLocalhostRedirects is set.
ErrRedirLocalhost = errors.New("Redirecting to localhost")
// ErrTooManyRedirects is returned when the number of HTTP redirects exceeds
// MaxRedirects.
ErrTooManyRedirects = errors.New("Too many redirects")
)
// Flags holds the command-line configuration for the HTTP scan module.
// Populated by the framework.
//
// TODO: Custom headers?
type Flags struct {
zgrab2.BaseFlags
zgrab2.TLSFlags
Method string `long:"method" default:"GET" description:"Set HTTP request method type"`
Endpoint string `long:"endpoint" default:"/" description:"Send an HTTP request to an endpoint"`
FailHTTPToHTTPS bool `long:"fail-http-to-https" description:"Trigger retry-https logic on known HTTP/400 protocol mismatch responses"`
UserAgent string `long:"user-agent" default:"Mozilla/5.0 zgrab/0.x" description:"Set a custom user agent"`
RetryHTTPS bool `long:"retry-https" description:"If the initial request fails, reconnect and try with HTTPS."`
MaxSize int `long:"max-size" default:"256" description:"Max kilobytes to read in response to an HTTP request"`
MaxRedirects int `long:"max-redirects" default:"0" description:"Max number of redirects to follow"`
// FollowLocalhostRedirects overrides the default behavior to return
// ErrRedirLocalhost whenever a redirect points to localhost.
FollowLocalhostRedirects bool `long:"follow-localhost-redirects" description:"Follow HTTP redirects to localhost"`
// UseHTTPS causes the first request to be over TLS, without requiring a
// redirect to HTTPS. It does not change the port used for the connection.
UseHTTPS bool `long:"use-https" description:"Perform an HTTPS connection on the initial host"`
// RedirectsSucceed causes the ErrTooManRedirects error to be suppressed
RedirectsSucceed bool `long:"redirects-succeed" description:"Redirects are always a success, even if max-redirects is exceeded"`
// Set arbitrary HTTP headers
CustomHeadersNames string `long:"custom-headers-names" description:"CSV of custom HTTP headers to send to server"`
CustomHeadersValues string `long:"custom-headers-values" description:"CSV of custom HTTP header values to send to server. Should match order of custom-headers-names."`
CustomHeadersDelimiter string `long:"custom-headers-delimiter" description:"Delimiter for customer header name/value CSVs"`
OverrideSH bool `long:"override-sig-hash" description:"Override the default SignatureAndHashes TLS option with more expansive default"`
// ComputeDecodedBodyHashAlgorithm enables computing the body hash later than the default,
// using the specified algorithm, allowing a user of the response to recompute a matching hash
ComputeDecodedBodyHashAlgorithm string `long:"compute-decoded-body-hash-algorithm" choice:"sha256" choice:"sha1" description:"Choose algorithm for BodyHash field"`
// WithBodyLength enables adding the body_size field to the Response
WithBodyLength bool `long:"with-body-size" description:"Enable the body_size attribute, for how many bytes actually read"`
}
// A Results object is returned by the HTTP module's Scanner.Scan()
// implementation.
type Results struct {
// Result is the final HTTP response in the RedirectResponseChain
Response *http.Response `json:"response,omitempty"`
// RedirectResponseChain is non-empty is the scanner follows a redirect.
// It contains all redirect response prior to the final response.
RedirectResponseChain []*http.Response `json:"redirect_response_chain,omitempty"`
}
// Module is an implementation of the zgrab2.Module interface.
type Module struct {
}
// Scanner is the implementation of the zgrab2.Scanner interface.
type Scanner struct {
config *Flags
customHeaders map[string]string
decodedHashFn func([]byte) string
}
// scan holds the state for a single scan. This may entail multiple connections.
// It is used to implement the zgrab2.Scanner interface.
type scan struct {
connections []net.Conn
scanner *Scanner
target *zgrab2.ScanTarget
transport *http.Transport
client *http.Client
results Results
url string
globalDeadline time.Time
}
// NewFlags returns an empty Flags object.
func (module *Module) NewFlags() interface{} {
return new(Flags)
}
// NewScanner returns a new instance Scanner instance.
func (module *Module) NewScanner() zgrab2.Scanner {
return new(Scanner)
}
// Description returns an overview of this module.
func (module *Module) Description() string {
return "Send an HTTP request and read the response, optionally following redirects."
}
// Validate performs any needed validation on the arguments
func (flags *Flags) Validate(args []string) error {
return nil
}
// Help returns module-specific help
func (flags *Flags) Help() string {
return ""
}
// Protocol returns the protocol identifer for the scanner.
func (scanner *Scanner) Protocol() string {
return "http"
}
// Init initializes the scanner with the given flags
func (scanner *Scanner) Init(flags zgrab2.ScanFlags) error {
fl, _ := flags.(*Flags)
scanner.config = fl
// parse out custom headers at initialization so that they can be easily
// iterated over when constructing individual scanners
if len(fl.CustomHeadersNames) > 0 || len(fl.CustomHeadersValues) > 0 {
if len(fl.CustomHeadersNames) == 0 {
log.Panicf("custom-headers-names must be specified if custom-headers-values is provided")
}
if len(fl.CustomHeadersValues) == 0 {
log.Panicf("custom-headers-values must be specified if custom-headers-names is provided")
}
namesReader := csv.NewReader(strings.NewReader(fl.CustomHeadersNames))
if namesReader == nil {
log.Panicf("unable to read custom-headers-names in CSV reader")
}
valuesReader := csv.NewReader(strings.NewReader(fl.CustomHeadersValues))
if valuesReader == nil {
log.Panicf("unable to read custom-headers-values in CSV reader")
}
// By default, the CSV delimiter will remain a comma unless explicitly specified
if len(fl.CustomHeadersDelimiter) > 1 {
log.Panicf("Invalid delimiter custom-header delimiter, must be a single character")
} else if fl.CustomHeadersDelimiter != "" {
valuesReader.Comma = rune(fl.CustomHeadersDelimiter[0])
namesReader.Comma = rune(fl.CustomHeadersDelimiter[0])
}
headerNames, err := namesReader.Read()
if err != nil {
return err
}
headerValues, err := valuesReader.Read()
if err != nil {
return err
}
if len(headerNames) != len(headerValues) {
log.Panicf("inconsistent number of HTTP header names and values")
}
scanner.customHeaders = make(map[string]string)
for i := 0; i < len(headerNames); i++ {
// The case of header names is normalized to title case later by HTTP library
// explicitly ToLower() to catch duplicates more easily
hName := strings.ToLower(headerNames[i])
switch {
case hName == "host":
log.Panicf("Attempt to set immutable header 'Host', specify this in targets file")
case hName == "user-agent":
log.Panicf("Attempt to set special header 'User-Agent', use --user-agent instead")
case hName == "content-length":
log.Panicf("Attempt to set immutable header 'Content-Length'")
}
// Disallow duplicate headers
_, ok := scanner.customHeaders[hName]
if ok {
log.Panicf("Attempt to set same custom header twice")
}
scanner.customHeaders[hName] = headerValues[i]
}
}
if fl.ComputeDecodedBodyHashAlgorithm == "sha1" {
scanner.decodedHashFn = func(body []byte) string {
rawHash := sha1.Sum(body)
return fmt.Sprintf("sha1:%s", hex.EncodeToString(rawHash[:]))
}
} else if fl.ComputeDecodedBodyHashAlgorithm == "sha256" {
scanner.decodedHashFn = func(body []byte) string {
rawHash := sha256.Sum256(body)
return fmt.Sprintf("sha256:%s", hex.EncodeToString(rawHash[:]))
}
} else if fl.ComputeDecodedBodyHashAlgorithm != "" {
log.Panicf("Invalid ComputeDecodedBodyHashAlgorithm choice made it through zflags: %s", scanner.config.ComputeDecodedBodyHashAlgorithm)
}
return nil
}
// InitPerSender does nothing in this module.
func (scanner *Scanner) InitPerSender(senderID int) error {
return nil
}
// GetName returns the name defined in the Flags.
func (scanner *Scanner) GetName() string {
return scanner.config.Name
}
// GetTrigger returns the Trigger defined in the Flags.
func (scanner *Scanner) GetTrigger() string {
return scanner.config.Trigger
}
// Cleanup closes any connections that have been opened during the scan
func (scan *scan) Cleanup() {
if scan.connections != nil {
for _, conn := range scan.connections {
defer conn.Close()
}
scan.connections = nil
}
}
// Get a context whose deadline is the earliest of the context's deadline (if it has one) and the
// global scan deadline.
func (scan *scan) withDeadlineContext(ctx context.Context) context.Context {
ctxDeadline, ok := ctx.Deadline()
if !ok || scan.globalDeadline.Before(ctxDeadline) {
ret, _ := context.WithDeadline(ctx, scan.globalDeadline)
return ret
}
return ctx
}
// Dial a connection using the configured timeouts, as well as the global deadline, and on success,
// add the connection to the list of connections to be cleaned up.
func (scan *scan) dialContext(ctx context.Context, network string, addr string) (net.Conn, error) {
dialer := zgrab2.GetTimeoutConnectionDialer(scan.scanner.config.Timeout)
switch network {
case "tcp", "tcp4", "tcp6", "udp", "udp4", "udp6":
// If the scan is for a specific IP, and a domain name is provided, we
// don't want to just let the http library resolve the domain. Create
// a fake resolver that we will use, that always returns the IP we are
// given to scan.
if scan.target.IP != nil && scan.target.Domain != "" {
host, _, err := net.SplitHostPort(addr)
if err != nil {
log.Errorf("http/scanner.go dialContext: unable to split host:port '%s'", addr)
log.Errorf("No fake resolver, IP address may be incorrect: %s", err)
} else {
// In the case of redirects, we don't want to blindly use the
// IP we were given to scan, however. Only use the fake
// resolver if the domain originally specified for the scan
// target matches the current address being looked up in this
// DialContext.
if host == scan.target.Domain {
resolver, err := zgrab2.NewFakeResolver(scan.target.IP.String())
if err != nil {
return nil, err
}
dialer.Dialer.Resolver = resolver
}
}
}
}
timeoutContext, _ := context.WithTimeout(context.Background(), scan.scanner.config.Timeout)
conn, err := dialer.DialContext(scan.withDeadlineContext(timeoutContext), network, addr)
if err != nil {
return nil, err
}
scan.connections = append(scan.connections, conn)
return conn, nil
}
// getTLSDialer returns a Dial function that connects using the
// zgrab2.GetTLSConnection()
func (scan *scan) getTLSDialer(t *zgrab2.ScanTarget) func(network, addr string) (net.Conn, error) {
return func(network, addr string) (net.Conn, error) {
outer, err := scan.dialContext(context.Background(), network, addr)
if err != nil {
return nil, err
}
cfg, err := scan.scanner.config.TLSFlags.GetTLSConfigForTarget(t)
if err != nil {
return nil, err
}
// Set SNI server name on redirects unless --server-name was used (issue #300)
// - t.Domain is always set to the *original* Host so it's not useful for setting SNI
// - host is the current target of the request in this context; this is true for the
// initial request as well as subsequent requests caused by redirects
// - scan.scanner.config.ServerName is the value from --server-name if one was specified
// If SNI is enabled and --server-name is not set, use the target host for the SNI server name
if !scan.scanner.config.NoSNI && scan.scanner.config.ServerName == "" {
host, _, err := net.SplitHostPort(addr)
if err != nil {
log.Errorf("getTLSDialer(): Something went wrong splitting host/port '%s': %s", addr, err)
}
// RFC4366: Literal IPv4 and IPv6 addresses are not permitted in "HostName"
if i := net.ParseIP(host); i == nil {
cfg.ServerName = host
}
}
if scan.scanner.config.OverrideSH {
cfg.SignatureAndHashes = []tls.SigAndHash{
{0x01, 0x04}, // rsa, sha256
{0x03, 0x04}, // ecdsa, sha256
{0x01, 0x02}, // rsa, sha1
{0x03, 0x02}, // ecdsa, sha1
{0x01, 0x04}, // rsa, sha256
{0x01, 0x05}, // rsa, sha384
{0x01, 0x06}, // rsa, sha512
}
}
tlsConn := scan.scanner.config.TLSFlags.GetWrappedConnection(outer, cfg)
// lib/http/transport.go fills in the TLSLog in the http.Request instance(s)
err = tlsConn.Handshake()
return tlsConn, err
}
}
// Taken from zgrab/zlib/grabber.go -- check if the URL points to localhost
func redirectsToLocalhost(host string) bool {
if i := net.ParseIP(host); i != nil {
return i.IsLoopback() || i.Equal(net.IPv4zero)
}
if host == "localhost" {
return true
}
if addrs, err := net.LookupHost(host); err == nil {
for _, i := range addrs {
if ip := net.ParseIP(i); ip != nil {
if ip.IsLoopback() || ip.Equal(net.IPv4zero) {
return true
}
}
}
}
return false
}
// Taken from zgrab/zlib/grabber.go -- get a CheckRedirect callback that uses
// the redirectToLocalhost and MaxRedirects config
func (scan *scan) getCheckRedirect() func(*http.Request, *http.Response, []*http.Request) error {
return func(req *http.Request, res *http.Response, via []*http.Request) error {
if !scan.scanner.config.FollowLocalhostRedirects && redirectsToLocalhost(req.URL.Hostname()) {
return ErrRedirLocalhost
}
scan.results.RedirectResponseChain = append(scan.results.RedirectResponseChain, res)
b := new(bytes.Buffer)
maxReadLen := int64(scan.scanner.config.MaxSize) * 1024
readLen := maxReadLen
if res.ContentLength >= 0 && res.ContentLength < maxReadLen {
readLen = res.ContentLength
}
bytesRead, _ := io.CopyN(b, res.Body, readLen)
if scan.scanner.config.WithBodyLength {
res.BodyTextLength = bytesRead
}
res.BodyText = b.String()
if len(res.BodyText) > 0 {
if scan.scanner.decodedHashFn != nil {
res.BodyHash = scan.scanner.decodedHashFn([]byte(res.BodyText))
} else {
m := sha256.New()
m.Write(b.Bytes())
res.BodySHA256 = m.Sum(nil)
}
}
if len(via) > scan.scanner.config.MaxRedirects {
return ErrTooManyRedirects
}
return nil
}
}
// Maps URL protocol to the default port for that protocol
var protoToPort = map[string]uint16{
"http": 80,
"https": 443,
}
// getHTTPURL gets the HTTP URL (sans default port) for the given protocol/host/port/endpoint.
func getHTTPURL(https bool, host string, port uint16, endpoint string) string {
var proto string
if https {
proto = "https"
} else {
proto = "http"
}
if protoToPort[proto] == port {
return proto + "://" + host + endpoint
}
return proto + "://" + net.JoinHostPort(host, strconv.FormatUint(uint64(port), 10)) + endpoint
}
// NewHTTPScan gets a new Scan instance for the given target
func (scanner *Scanner) newHTTPScan(t *zgrab2.ScanTarget, useHTTPS bool) *scan {
ret := scan{
scanner: scanner,
target: t,
transport: &http.Transport{
Proxy: nil, // TODO: implement proxying
DisableKeepAlives: false,
DisableCompression: false,
MaxIdleConnsPerHost: scanner.config.MaxRedirects,
},
client: http.MakeNewClient(),
globalDeadline: time.Now().Add(scanner.config.Timeout),
}
ret.transport.DialTLS = ret.getTLSDialer(t)
ret.transport.DialContext = ret.dialContext
ret.client.UserAgent = scanner.config.UserAgent
ret.client.CheckRedirect = ret.getCheckRedirect()
ret.client.Transport = ret.transport
ret.client.Jar = nil // Don't send or receive cookies (otherwise use CookieJar)
ret.client.Timeout = scanner.config.Timeout
host := t.Domain
if host == "" {
host = t.IP.String()
}
// Scanner Target port overrides config flag port
var port uint16
if t.Port != nil {
port = uint16(*t.Port)
} else {
port = uint16(scanner.config.BaseFlags.Port)
}
ret.url = getHTTPURL(useHTTPS, host, port, scanner.config.Endpoint)
return &ret
}
// Grab performs the HTTP scan -- implementation taken from zgrab/zlib/grabber.go
func (scan *scan) Grab() *zgrab2.ScanError {
// TODO: Allow body?
request, err := http.NewRequest(scan.scanner.config.Method, scan.url, nil)
if err != nil {
return zgrab2.NewScanError(zgrab2.SCAN_UNKNOWN_ERROR, err)
}
// By default, the following headers are *always* set:
// Host, User-Agent, Accept, Accept-Encoding
if scan.scanner.customHeaders != nil {
request.Header.Set("Accept", "*/*")
for k, v := range scan.scanner.customHeaders {
request.Header.Set(k, v)
}
} else {
// If user did not specify custom headers, legacy behavior has always been
// to set the Accept header
request.Header.Set("Accept", "*/*")
}
resp, err := scan.client.Do(request)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
scan.results.Response = resp
if err != nil {
if urlError, ok := err.(*url.Error); ok {
err = urlError.Err
}
}
if err != nil {
switch err {
case ErrRedirLocalhost:
break
case ErrTooManyRedirects:
if scan.scanner.config.RedirectsSucceed {
return nil
}
return zgrab2.NewScanError(zgrab2.SCAN_APPLICATION_ERROR, err)
default:
return zgrab2.DetectScanError(err)
}
}
buf := new(bytes.Buffer)
maxReadLen := int64(scan.scanner.config.MaxSize) * 1024
readLen := maxReadLen
if resp.ContentLength >= 0 && resp.ContentLength < maxReadLen {
readLen = resp.ContentLength
}
io.CopyN(buf, resp.Body, readLen)
encoder, encoding, certain := charset.DetermineEncoding(buf.Bytes(), resp.Header.Get("content-type"))
bodyText := ""
decodedSuccessfully := false
decoder := encoder.NewDecoder()
//"windows-1252" is the default value and will likely not decode correctly
if certain || encoding != "windows-1252" {
decoded, decErr := decoder.Bytes(buf.Bytes())
if decErr == nil {
bodyText = string(decoded)
decodedSuccessfully = true
}
}
if !decodedSuccessfully {
bodyText = buf.String()
}
// Application-specific logic for retrying HTTP as HTTPS; if condition matches, return protocol error
if scan.scanner.config.FailHTTPToHTTPS && scan.results.Response.StatusCode == 400 && readLen < 1024 && readLen > 24 {
// Apache: "You're speaking plain HTTP to an SSL-enabled server port"
// NGINX: "The plain HTTP request was sent to HTTPS port"
var sliceLen int64 = 128
if readLen < sliceLen {
sliceLen = readLen
}
sliceBuf := bodyText[:sliceLen]
if strings.Contains(sliceBuf, "The plain HTTP request was sent to HTTPS port") ||
strings.Contains(sliceBuf, "You're speaking plain HTTP") ||
strings.Contains(sliceBuf, "combination of host and port requires TLS") ||
strings.Contains(sliceBuf, "Client sent an HTTP request to an HTTPS server") {
return zgrab2.NewScanError(zgrab2.SCAN_PROTOCOL_ERROR, errors.New("NGINX or Apache HTTP over HTTPS failure"))
}
}
// re-enforce readlen
if int64(len(bodyText)) > readLen {
scan.results.Response.BodyText = bodyText[:int(readLen)]
} else {
scan.results.Response.BodyText = bodyText
}
if scan.scanner.config.WithBodyLength {
scan.results.Response.BodyTextLength = int64(len(scan.results.Response.BodyText))
}
if len(scan.results.Response.BodyText) > 0 {
if scan.scanner.decodedHashFn != nil {
scan.results.Response.BodyHash = scan.scanner.decodedHashFn([]byte(scan.results.Response.BodyText))
} else {
m := sha256.New()
m.Write(buf.Bytes())
scan.results.Response.BodySHA256 = m.Sum(nil)
}
}
return nil
}
// Scan implements the zgrab2.Scanner interface and performs the full scan of
// the target. If the scanner is configured to follow redirects, this may entail
// multiple TCP connections to hosts other than target.
func (scanner *Scanner) Scan(t zgrab2.ScanTarget) (zgrab2.ScanStatus, interface{}, error) {
scan := scanner.newHTTPScan(&t, scanner.config.UseHTTPS)
defer scan.Cleanup()
err := scan.Grab()
if err != nil {
if scanner.config.RetryHTTPS && !scanner.config.UseHTTPS {
scan.Cleanup()
retry := scanner.newHTTPScan(&t, true)
defer retry.Cleanup()
retryError := retry.Grab()
if retryError != nil {
return retryError.Unpack(&retry.results)
}
return zgrab2.SCAN_SUCCESS, &retry.results, nil
}
return err.Unpack(&scan.results)
}
return zgrab2.SCAN_SUCCESS, &scan.results, nil
}
// RegisterModule is called by modules/http.go to register this module with the
// zgrab2 framework.
func RegisterModule() {
var module Module
_, err := zgrab2.AddCommand("http", "HTTP Banner Grab", module.Description(), 80, &module)
if err != nil {
log.Fatal(err)
}
}