diff --git a/cmd/goctopus/goctopus.go b/cmd/goctopus/goctopus.go index 9434909..84a82a7 100644 --- a/cmd/goctopus/goctopus.go +++ b/cmd/goctopus/goctopus.go @@ -3,7 +3,6 @@ package main import ( "os" - "github.com/Escape-Technologies/goctopus/internal/utils" "github.com/Escape-Technologies/goctopus/pkg/config" "github.com/Escape-Technologies/goctopus/pkg/goctopus" @@ -12,9 +11,6 @@ import ( func main() { config.LoadFromArgs() - if !config.Get().Silent { - utils.PrintASCII() - } if config.Get().InputFile != "" { input, err := os.Open(config.Get().InputFile) diff --git a/pkg/config/config.go b/pkg/config/config.go index 0bfe89c..3cc7ef0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -22,6 +22,7 @@ type Config struct { FieldSuggestion bool WebhookUrl string SubdomainEnumeration bool + EngineFingerprinting bool } var ( @@ -61,9 +62,10 @@ func LoadFromArgs() { flag.BoolVar(&config.Introspection, "introspect", false, "Enable introspection fingerprinting") flag.BoolVar(&config.FieldSuggestion, "suggest", false, "Enable fields suggestion fingerprinting.\nNeeds \"introspection\" to be enabled.") flag.BoolVar(&config.SubdomainEnumeration, "subdomain", false, "Enable subdomain enumeration") + flag.BoolVar(&config.EngineFingerprinting, "engine", false, "[Experimental] Enable GraphQL engine fingerprinting") // -a (All) flag enables all fingerprinting methods - all := flag.Bool("a", false, "(All) Enable all fingerprinting methods: introspection, field suggestion, subdomain enumeration") + all := flag.Bool("a", false, "(All) Enable all stable fingerprinting methods: introspection, field suggestion, subdomain enumeration") flag.Parse() @@ -85,6 +87,10 @@ func LoadFromArgs() { log.SetLevel(log.ErrorLevel) } + if !config.Silent { + utils.PrintASCII() + } + if err := validateConfig(&config, true); err != nil { log.Error(err) flag.PrintDefaults() @@ -114,6 +120,10 @@ func validateConfig(conf *Config, isCli bool) error { return errors.New("[Invalid config] Please specify an input file or a list of addresses") } + if conf.EngineFingerprinting { + log.Warn("[Experimental] GraphQL engine fingerprinting is enabled. This feature is experimental and may produce false positives. Contributions are welcome to add new engines and test the feature.") + } + return nil } @@ -133,4 +143,8 @@ func Load(config *Config) { if c.Silent { log.SetLevel(log.ErrorLevel) } + + if !config.Silent { + utils.PrintASCII() + } } diff --git a/pkg/endpoint/endpoint.go b/pkg/endpoint/endpoint.go index bd0d891..6516ebc 100644 --- a/pkg/endpoint/endpoint.go +++ b/pkg/endpoint/endpoint.go @@ -2,6 +2,7 @@ package endpoint import ( "github.com/Escape-Technologies/goctopus/pkg/address" + "github.com/Escape-Technologies/goctopus/pkg/engine" "github.com/Escape-Technologies/goctopus/pkg/graphql" "github.com/Escape-Technologies/goctopus/pkg/http" "github.com/Escape-Technologies/goctopus/pkg/introspection" @@ -18,6 +19,7 @@ type endpointFingerprinter interface { IsAuthenticatedGraphql() (bool, error) HasFieldSuggestion() (bool, error) HasIntrospectionOpen() (bool, error) + GetEngine() string } func NewEndpointFingerprinter(url *address.Addr, client http.Client) endpointFingerprinter { @@ -42,3 +44,7 @@ func (e *_endpointFingerprinter) HasFieldSuggestion() (bool, error) { func (e *_endpointFingerprinter) HasIntrospectionOpen() (bool, error) { return introspection.FingerprintIntrospection(e.url.Address, e.client) } + +func (e *_endpointFingerprinter) GetEngine() string { + return engine.FingerprintEngine(e.url.Address, e.client) +} diff --git a/pkg/endpoint/fingerprint.go b/pkg/endpoint/fingerprint.go index 17c1d52..25a6608 100644 --- a/pkg/endpoint/fingerprint.go +++ b/pkg/endpoint/fingerprint.go @@ -23,6 +23,10 @@ func fingerprintEndpoint(url *address.Addr, e endpointFingerprinter, config *con return nil, err } + if config.EngineFingerprinting { + out.Engine = e.GetEngine() + } + if !isOpenGraphql { isAuthenticatedGraphql, err := e.IsAuthenticatedGraphql() if err != nil { diff --git a/pkg/endpoint/fingerprint_test.go b/pkg/endpoint/fingerprint_test.go index 3a7b70b..811fdcf 100644 --- a/pkg/endpoint/fingerprint_test.go +++ b/pkg/endpoint/fingerprint_test.go @@ -33,6 +33,10 @@ func (m *mockedEndpointFingerprinter) HasFieldSuggestion() (bool, error) { return m.fieldSuggestion, nil } +func (m *mockedEndpointFingerprinter) GetEngine() string { + return "" +} + func (m *mockedEndpointFingerprinter) Close() {} func makeMockedEndpointFingerprinter(graphql bool, introspection bool) *mockedEndpointFingerprinter { @@ -42,7 +46,7 @@ func makeMockedEndpointFingerprinter(graphql bool, introspection bool) *mockedEn } } -// @todo test field suggestion +// @todo test field suggestion & engine fingerprinting func TestFingerprintUrl(t *testing.T) { url := &address.Addr{ diff --git a/pkg/engine/TEMPLATE.go b/pkg/engine/TEMPLATE.go new file mode 100644 index 0000000..fc4986a --- /dev/null +++ b/pkg/engine/TEMPLATE.go @@ -0,0 +1,11 @@ +package engine + +var T = &engine{ + Name: "", + Imprints: []imprint{ + { + Query: "", + Matcher: inResponseText([]string{""}), + }, + }, +} diff --git a/pkg/engine/adriane.go b/pkg/engine/adriane.go new file mode 100644 index 0000000..33fa267 --- /dev/null +++ b/pkg/engine/adriane.go @@ -0,0 +1,15 @@ +package engine + +var Adriane = &engine{ + Name: "Adriane", + Imprints: []imprint{ + { + Query: "", + Matcher: inResponseText([]string{"The query must be a string."}), + }, + { + Query: "query { __typename @abc }", + Matcher: inResponseText([]string{"Unknown directive '@abc'."}), + }, + }, +} diff --git a/pkg/engine/agoo.go b/pkg/engine/agoo.go new file mode 100644 index 0000000..3dacd80 --- /dev/null +++ b/pkg/engine/agoo.go @@ -0,0 +1,11 @@ +package engine + +var Agoo = &engine{ + Name: "agoo", + Imprints: []imprint{ + { + Query: "query { zzz }", + Matcher: inSection("code", []string{"eval error"}), + }, + }, +} diff --git a/pkg/engine/apollo.go b/pkg/engine/apollo.go new file mode 100644 index 0000000..abe2b56 --- /dev/null +++ b/pkg/engine/apollo.go @@ -0,0 +1,21 @@ +package engine + +var Apollo = &engine{ + Name: "Apollo", + Imprints: []imprint{ + { + Query: "query @deprecated { __typename }", + Matcher: inResponseText([]string{ + "Directive \\\"@deprecated\\\" may not be used on QUERY.", + "Directive \\\"deprecated\\\" may not be used on QUERY.", + }), + }, + { + Query: "query @skip { __typename }", + Matcher: inResponseText([]string{ + "Directive \\\"@skip\\\" argument \\\"if\\\" of type \\\"Boolean!\\\" is required, but it was not provided", + "Directive \\\"skip\\\" argument \\\"if\\\" of type \\\"Boolean!\\\" is required, but it was not provided", + }), + }, + }, +} diff --git a/pkg/engine/aws_app_sync.go b/pkg/engine/aws_app_sync.go new file mode 100644 index 0000000..a685969 --- /dev/null +++ b/pkg/engine/aws_app_sync.go @@ -0,0 +1,11 @@ +package engine + +var AWSAppSync = &engine{ + Name: "AWSAppSync", + Imprints: []imprint{ + { + Query: "query @skip { __typename }", + Matcher: inResponseText([]string{"MisplacedDirective"}), + }, + }, +} diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go new file mode 100644 index 0000000..19b4039 --- /dev/null +++ b/pkg/engine/engine.go @@ -0,0 +1,125 @@ +package engine + +import ( + "bytes" + "encoding/json" + + "github.com/Escape-Technologies/goctopus/pkg/http" + + log "github.com/sirupsen/logrus" +) + +type engine struct { + Name string + Imprints []imprint +} + +type imprint struct { + Query string + Matcher matcher +} + +// A responseMatcher is a function that takes a response body and returns true if the response matches the engine. +type matcher func(responseBody *[]byte) bool + +// inResponseText returns a responseMatcher that checks if the response body contains any of the given strings. +func inResponseText(matches []string) matcher { + return func(responseBody *[]byte) bool { + for _, match := range matches { + if bytes.Contains(*responseBody, []byte(match)) { + return true + } + } + return false + } +} + +// inSection returns a responseMatcher that checks if the response body contains any of the given strings in the given section. +func inSection(section string, matches []string) matcher { + return func(responseBody *[]byte) bool { + var reponseBody map[string]interface{} + err := json.Unmarshal(*responseBody, &reponseBody) + if err != nil { + log.Debugf("Error unmarshalling response body: %v", err) + return false + } + content, err := json.Marshal(reponseBody[section]) + if err != nil { + return false + } + for _, match := range matches { + if bytes.Contains(content, []byte(match)) { + return true + } + } + return false + } +} + +// hasJsonKey returns a responseMatcher that checks if the response body contains the given key. +func hasJsonKey(key string) matcher { + return func(responseBody *[]byte) bool { + var reponseBody map[string]interface{} + json.Unmarshal(*responseBody, &reponseBody) + _, ok := reponseBody[key] + return ok + } +} + +// Order is important here, as the first match will be returned. +// The order has been determined by the usage statistics of the engines. (The higher the usage, the higher the priority.) +var Engines = []*engine{ + Apollo, + AWSAppSync, + Agoo, + GraphQLGo, + Ruby, + GraphQLPHP, + Graphene, + Adriane, + GraphQLGopherGo, +} + +func addScore(engineName string, scores map[string]int) { + if _, ok := scores[engineName]; !ok { + scores[engineName] = 0 + } + scores[engineName]++ +} + +func FingerprintEngine(url string, client http.Client) string { + scores := make(map[string]int) // engine name -> score + for _, engine := range Engines { + for _, imprint := range engine.Imprints { + log.Debugf("Trying to match %s with %s", imprint.Query, engine.Name) + requestBody := http.QueryToRequestBody(imprint.Query) + resp, err := client.Post(url, []byte(requestBody)) + if err != nil { + log.Debugf("Error from %v: %v", url, err) + continue + } + log.Debugf("Response: %s", resp.Body) + if imprint.Matcher(resp.Body) { + addScore(engine.Name, scores) + } + } + } + + log.Debugf("Scores for %s: %v", url, scores) + + // Find the engine with the highest score. + var maxScore int + var maxScoreEngine string + for engineName, score := range scores { + if score > maxScore { + maxScore = score + maxScoreEngine = engineName + } + } + + if maxScore > 0 { + return maxScoreEngine + } + + return "unknown" +} diff --git a/pkg/engine/graphene.go b/pkg/engine/graphene.go new file mode 100644 index 0000000..fea6d52 --- /dev/null +++ b/pkg/engine/graphene.go @@ -0,0 +1,11 @@ +package engine + +var Graphene = &engine{ + Name: "Graphene", + Imprints: []imprint{ + { + Query: "query { aaa }", + Matcher: inResponseText([]string{"Syntax Error GraphQL (1:1)"}), + }, + }, +} diff --git a/pkg/engine/graphql_go.go b/pkg/engine/graphql_go.go new file mode 100644 index 0000000..263e0bb --- /dev/null +++ b/pkg/engine/graphql_go.go @@ -0,0 +1,19 @@ +package engine + +var GraphQLGo = &engine{ + Name: "GraphQLGo", + Imprints: []imprint{ + { + Query: "", + Matcher: inResponseText([]string{"Must provide an operation."}), + }, + { + Query: "query { __typename {}", + Matcher: inResponseText([]string{"Unexpected empty IN"}), + }, + { + Query: "query { __typename }", + Matcher: inResponseText([]string{"RootQuery"}), + }, + }, +} diff --git a/pkg/engine/graphql_gopher_go.go b/pkg/engine/graphql_gopher_go.go new file mode 100644 index 0000000..4a21dc4 --- /dev/null +++ b/pkg/engine/graphql_gopher_go.go @@ -0,0 +1,11 @@ +package engine + +var GraphQLGopherGo = &engine{ + Name: "", + Imprints: []imprint{ + { + Query: "query {}", + Matcher: hasJsonKey("data"), + }, + }, +} diff --git a/pkg/engine/graphql_java.go b/pkg/engine/graphql_java.go new file mode 100644 index 0000000..47fead9 --- /dev/null +++ b/pkg/engine/graphql_java.go @@ -0,0 +1,19 @@ +package engine + +var GraphQLJava = &engine{ + Name: "", + Imprints: []imprint{ + { + Query: "", + Matcher: inResponseText([]string{"Invalid Syntax : offending token ''"}), + }, + { + Query: "query @aaa@aaa { __typename }", + Matcher: inResponseText([]string{"Validation error of type DuplicateDirectiveName: Directives must be uniquely named within a location."}), + }, + { + Query: "queryy { __typename }", + Matcher: inResponseText([]string{"Invalid Syntax : offending token 'queryy'"}), + }, + }, +} diff --git a/pkg/engine/graphql_php.go b/pkg/engine/graphql_php.go new file mode 100644 index 0000000..f906957 --- /dev/null +++ b/pkg/engine/graphql_php.go @@ -0,0 +1,15 @@ +package engine + +var GraphQLPHP = &engine{ + Name: "GraphQLPHP", + Imprints: []imprint{ + { + Query: "query ! {__typename}", + Matcher: inResponseText([]string{"Syntax Error: Cannot parse the unexpected character \"?\"."}), + }, + { + Query: "query @deprecated {__typename}", + Matcher: inResponseText([]string{"Directive \"deprecated\" may not be used on \"QUERY\"."}), + }, + }, +} diff --git a/pkg/engine/ruby.go b/pkg/engine/ruby.go new file mode 100644 index 0000000..1f38971 --- /dev/null +++ b/pkg/engine/ruby.go @@ -0,0 +1,23 @@ +package engine + +var Ruby = &engine{ + Name: "Ruby", + Imprints: []imprint{ + { + Query: "query @deprecated { __typename }", + Matcher: inResponseText([]string{"'@deprecated' can't be applied to queries"}), + }, + { + Query: "query @skip { __typename }", + Matcher: inResponseText([]string{"'@skip' can't be applied to queries (allowed: fields, fragment spreads, inline fragments)"}), + }, + { + Query: "query { __typename @skip }", + Matcher: inResponseText([]string{"Directive 'skip' is missing required arguments: if"}), + }, + { + Query: "query { __typename {}", + Matcher: inResponseText([]string{"Parse error on \"}\" (RCURLY)"}), + }, + }, +} diff --git a/pkg/http/http.go b/pkg/http/http.go index 61934e0..ad6213c 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -3,6 +3,7 @@ package http import ( "crypto/tls" "errors" + "fmt" "sync" "time" @@ -134,3 +135,7 @@ func SendToWebhook(url string, body []byte, wg *sync.WaitGroup) error { } return nil } + +func QueryToRequestBody(query string) []byte { + return []byte(fmt.Sprintf(`{"query":"%s"}`, query)) +} diff --git a/pkg/output/output.go b/pkg/output/output.go index ade8bfc..9e01fd3 100644 --- a/pkg/output/output.go +++ b/pkg/output/output.go @@ -22,6 +22,7 @@ type FingerprintOutput struct { SchemaStatus SchemaStatus `json:"schema_status"` Source string `json:"source"` // the original address used to fingerprint the endpoint Metadata map[string]string `json:"metadata"` // optional metadata + Engine string `json:"engine"` } func (o *FingerprintOutput) MarshalJSON() ([]byte, error) {