Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions cmd/goctopus/goctopus.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type Config struct {
FieldSuggestion bool
WebhookUrl string
SubdomainEnumeration bool
EngineFingerprinting bool
}

var (
Expand Down Expand Up @@ -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()

Expand All @@ -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()
Expand Down Expand Up @@ -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
}

Expand All @@ -133,4 +143,8 @@ func Load(config *Config) {
if c.Silent {
log.SetLevel(log.ErrorLevel)
}

if !config.Silent {
utils.PrintASCII()
}
}
6 changes: 6 additions & 0 deletions pkg/endpoint/endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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)
}
4 changes: 4 additions & 0 deletions pkg/endpoint/fingerprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion pkg/endpoint/fingerprint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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{
Expand Down
11 changes: 11 additions & 0 deletions pkg/engine/TEMPLATE.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package engine

var T = &engine{
Name: "",
Imprints: []imprint{
{
Query: "",
Matcher: inResponseText([]string{""}),
},
},
}
15 changes: 15 additions & 0 deletions pkg/engine/adriane.go
Original file line number Diff line number Diff line change
@@ -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'."}),
},
},
}
11 changes: 11 additions & 0 deletions pkg/engine/agoo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package engine

var Agoo = &engine{
Name: "agoo",
Imprints: []imprint{
{
Query: "query { zzz }",
Matcher: inSection("code", []string{"eval error"}),
},
},
}
21 changes: 21 additions & 0 deletions pkg/engine/apollo.go
Original file line number Diff line number Diff line change
@@ -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",
}),
},
},
}
11 changes: 11 additions & 0 deletions pkg/engine/aws_app_sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package engine

var AWSAppSync = &engine{
Name: "AWSAppSync",
Imprints: []imprint{
{
Query: "query @skip { __typename }",
Matcher: inResponseText([]string{"MisplacedDirective"}),
},
},
}
125 changes: 125 additions & 0 deletions pkg/engine/engine.go
Original file line number Diff line number Diff line change
@@ -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"
}
11 changes: 11 additions & 0 deletions pkg/engine/graphene.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package engine

var Graphene = &engine{
Name: "Graphene",
Imprints: []imprint{
{
Query: "query { aaa }",
Matcher: inResponseText([]string{"Syntax Error GraphQL (1:1)"}),
},
},
}
19 changes: 19 additions & 0 deletions pkg/engine/graphql_go.go
Original file line number Diff line number Diff line change
@@ -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"}),
},
},
}
11 changes: 11 additions & 0 deletions pkg/engine/graphql_gopher_go.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package engine

var GraphQLGopherGo = &engine{
Name: "",
Imprints: []imprint{
{
Query: "query {}",
Matcher: hasJsonKey("data"),
},
},
}
19 changes: 19 additions & 0 deletions pkg/engine/graphql_java.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package engine

var GraphQLJava = &engine{
Name: "",
Imprints: []imprint{
{
Query: "",
Matcher: inResponseText([]string{"Invalid Syntax : offending token '<EOF>'"}),
},
{
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'"}),
},
},
}
Loading