Skip to content
Open
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
75 changes: 75 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ package config

import (
"bytes"
"context"
"errors"
"flag"
"fmt"
"io"
"net"
"strings"
"text/template"

Expand Down Expand Up @@ -123,6 +127,10 @@ type Config struct {
FileTemplateData string
// File format exploit output
FileFormatFilePath string
// Errors accumulates errors from protocol calls so exploit implementations
// can inspect the nature of failures after the fact.
// Use AddError to populate and HasTimeoutError/HasEOFError/HasErrors to query.
Errors []error
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment these error states don't get cleared here:

go-exploit/framework.go

Lines 578 to 600 in 3845e7e

for index, host := range conf.RhostsNTuple {
// setup the conf for the downstream exploit
conf.Rhost = host.Rhost
conf.Rport = host.Rport
switch host.SSL {
case config.SSLDisabled:
conf.SSL = false
conf.DetermineSSL = false
case config.SSLEnabled:
conf.SSL = true
conf.DetermineSSL = false
case config.SSLAutodiscover:
conf.SSL = false
conf.DetermineSSL = true
}
output.PrintFrameworkStatus("Starting target", "index", index, "host", conf.Rhost,
"port", conf.Rport, "ssl", conf.SSL, "ssl auto", conf.DetermineSSL)
if !doScan(sploit, conf) {
return
}
globalWG.Wait()
}

This will make it so that the errors continue through to multiple rhosts targets.

I was mulling this exact problem over recently and I think I've come to the conclusion that maybe we should add a Config.State struct that can hold the rhost specific state that also clears on a new iteration of conf.RhostsNTuple that also just clears that state for each iteration.

}

// Convert ExploitType to String.
Expand Down Expand Up @@ -545,3 +553,70 @@ func (conf *Config) HasCustomPayload() bool {

return len(conf.CustomPayload) > 0
}

// AddError appends err to the accumulated error list on the config context.
// It is intended to be called by the exploit implementation after receiving
// a failed protocol call, so the nature of the failure can be inspected later.
func (conf *Config) AddError(err error) {
conf.Errors = append(conf.Errors, err)
}

// HasErrors returns true if at least one error has been accumulated.
func (conf *Config) HasErrors() bool {
return len(conf.Errors) > 0
}

// HasTimeoutError returns true if any accumulated error is a network timeout.
// This covers both context.DeadlineExceeded and net.Error.Timeout(), which
// are the two forms observed in practice (see issue #415).
//
// Typical use — fire-and-forget payload where the server stops responding
// because it is executing the payload:
//
// client, req, ok := protocol.CreateRequest("POST", url, payload, true)
// if !ok {
// return false
// }
// _, _, err := protocol.DoRequestErr(client, req)
// if err != nil {
// conf.AddError(err)
// if conf.HasTimeoutError() {
// return true // timeout expected: payload executing on target
// }
// return false
// }
// return true
func (conf *Config) HasTimeoutError() bool {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is awesome!

for _, err := range conf.Errors {
if errors.Is(err, context.DeadlineExceeded) {
return true
}

var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
}

return false
}

// HasEOFError returns true if any accumulated error is an io.EOF.
// This typically means the server closed the connection after receiving
// the payload without sending an HTTP response — a common pattern in
// exploits that cause the target process to restart or crash.
func (conf *Config) HasEOFError() bool {
for _, err := range conf.Errors {
if errors.Is(err, io.EOF) {
return true
}
}

return false
}

// ClearErrors removes all accumulated errors from the config context.
// Useful when an exploit retries after a handled error.
func (conf *Config) ClearErrors() {
conf.Errors = nil
}
211 changes: 211 additions & 0 deletions config/config_errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
package config_test

import (
"context"
"errors"
"io"
"net"
"testing"
"time"

"github.com/vulncheck-oss/go-exploit/config"
)

// Sentinelles pour err113 — pas d'errors.New() inline dans les fonctions.
var (
errFirst = errors.New("first")
errSecond = errors.New("second")
errConnRefused = errors.New("connection refused")
errSomethingElse = errors.New("something else")
errOne = errors.New("one")
errSentinel = errors.New("sentinel")
)

func newTestConfig() *config.Config {
return &config.Config{}
}

// timeoutNetError implémente net.Error avec Timeout() == true.
type timeoutNetError struct{}

func (timeoutNetError) Error() string { return "fake timeout" }
func (timeoutNetError) Timeout() bool { return true }
func (timeoutNetError) Temporary() bool { return true }

// nonTimeoutNetError implémente net.Error avec Timeout() == false.
type nonTimeoutNetError struct{}

func (nonTimeoutNetError) Error() string { return "fake net error" }
func (nonTimeoutNetError) Timeout() bool { return false }
func (nonTimeoutNetError) Temporary() bool { return false }

// --- AddError / HasErrors ---

func TestAddError_AccumulatesErrors(t *testing.T) {
conf := newTestConfig()

if conf.HasErrors() {
t.Fatal("HasErrors() doit être false sur un Config vide")
}

conf.AddError(errFirst)
conf.AddError(errSecond)

if !conf.HasErrors() {
t.Fatal("HasErrors() doit être true après AddError")
}

if len(conf.Errors) != 2 {
t.Fatalf("attendu 2 erreurs, obtenu %d", len(conf.Errors))
}
}

func TestHasErrors_FalseOnEmpty(t *testing.T) {
conf := newTestConfig()
if conf.HasErrors() {
t.Fatal("HasErrors() doit être false sur un Config vide")
}
}

// --- HasTimeoutError ---

func TestHasTimeoutError_FalseOnEmpty(t *testing.T) {
conf := newTestConfig()
if conf.HasTimeoutError() {
t.Fatal("HasTimeoutError() doit être false sans erreurs")
}
}

func TestHasTimeoutError_FalseOnPlainError(t *testing.T) {
conf := newTestConfig()
conf.AddError(errConnRefused)
if conf.HasTimeoutError() {
t.Fatal("HasTimeoutError() doit être false pour une erreur générique")
}
}

func TestHasTimeoutError_TrueOnContextDeadline(t *testing.T) {
conf := newTestConfig()
conf.AddError(context.DeadlineExceeded)
if !conf.HasTimeoutError() {
t.Fatal("HasTimeoutError() doit être true pour context.DeadlineExceeded")
}
}

func TestHasTimeoutError_TrueOnExpiredContext(t *testing.T) {
conf := newTestConfig()
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
defer cancel()
conf.AddError(ctx.Err())
if !conf.HasTimeoutError() {
t.Fatal("HasTimeoutError() doit être true pour un contexte expiré")
}
}

func TestHasTimeoutError_TrueOnNetErrorTimeout(t *testing.T) {
conf := newTestConfig()
conf.AddError(timeoutNetError{})
if !conf.HasTimeoutError() {
t.Fatal("HasTimeoutError() doit être true pour net.Error.Timeout()=true")
}
}

func TestHasTimeoutError_FalseOnNetErrorNonTimeout(t *testing.T) {
conf := newTestConfig()
conf.AddError(nonTimeoutNetError{})
if conf.HasTimeoutError() {
t.Fatal("HasTimeoutError() doit être false pour net.Error.Timeout()=false")
}
}

func TestHasTimeoutError_TrueOnURLErrorWrappingDeadline(t *testing.T) {
conf := newTestConfig()
wrapped := &net.OpError{
Op: "read",
Err: context.DeadlineExceeded,
}
conf.AddError(wrapped)
if !conf.HasTimeoutError() {
t.Fatal("HasTimeoutError() doit être true pour net.OpError wrappant DeadlineExceeded")
}
}

// --- HasEOFError ---

func TestHasEOFError_FalseOnEmpty(t *testing.T) {
conf := newTestConfig()
if conf.HasEOFError() {
t.Fatal("HasEOFError() doit être false sans erreurs")
}
}

func TestHasEOFError_TrueOnIOEOF(t *testing.T) {
conf := newTestConfig()
conf.AddError(io.EOF)
if !conf.HasEOFError() {
t.Fatal("HasEOFError() doit être true pour io.EOF")
}
}

func TestHasEOFError_TrueOnWrappedEOF(t *testing.T) {
conf := newTestConfig()
wrapped := &net.OpError{
Op: "read",
Err: io.EOF,
}
conf.AddError(wrapped)
if !conf.HasEOFError() {
t.Fatal("HasEOFError() doit être true pour net.OpError wrappant io.EOF")
}
}

func TestHasEOFError_FalseOnNonEOF(t *testing.T) {
conf := newTestConfig()
conf.AddError(errSomethingElse)
if conf.HasEOFError() {
t.Fatal("HasEOFError() doit être false pour une erreur non-EOF")
}
}

// --- ClearErrors ---

func TestClearErrors_ResetsSlice(t *testing.T) {
conf := newTestConfig()
conf.AddError(errOne)
conf.AddError(context.DeadlineExceeded)
conf.AddError(io.EOF)

conf.ClearErrors()

if conf.HasErrors() {
t.Fatal("HasErrors() doit être false après ClearErrors")
}
if conf.HasTimeoutError() {
t.Fatal("HasTimeoutError() doit être false après ClearErrors")
}
if conf.HasEOFError() {
t.Fatal("HasEOFError() doit être false après ClearErrors")
}
if conf.Errors != nil {
t.Fatalf("Errors doit être nil après ClearErrors, obtenu %v", conf.Errors)
}
}

func TestClearErrors_SafeOnEmpty(t *testing.T) {
conf := newTestConfig()
conf.ClearErrors()
if conf.HasErrors() {
t.Fatal("HasErrors() doit rester false après ClearErrors sur config vide")
}
}

// --- Préservation de l'identité des erreurs ---

func TestErrorIdentityPreserved(t *testing.T) {
conf := newTestConfig()
conf.AddError(errSentinel)

if !errors.Is(conf.Errors[0], errSentinel) {
t.Fatal("errors.Is doit retrouver l'erreur originale dans conf.Errors")
}
}
20 changes: 20 additions & 0 deletions protocol/httphelper.go
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to add the error propagation directly into the protocol calls? I think I'd like to make this accessible uniformly across all HTTP calls!

Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,26 @@ func DoRequest(client *http.Client, req *http.Request) (*http.Response, string,
return resp, string(bodyBytes), true
}

// DoRequestErr behaves identically to DoRequest but returns the raw error
// instead of a bool on failure. This allows the calling exploit to inspect
// the error (e.g. via conf.AddError + conf.HasTimeoutError) rather than
// receiving only a success/failure signal.
//
// Use this variant for fire-and-forget payloads where a timeout or EOF is
// expected and should not be treated as a failure.
func DoRequestErr(client *http.Client, req *http.Request) (*http.Response, string, error) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might end up being a good place to apply #547 !

resp, err := client.Do(req)
if err != nil {
return resp, "", fmt.Errorf("HTTP request error: %w", err)
}

defer resp.Body.Close()

bodyBytes, _ := io.ReadAll(resp.Body)

return resp, string(bodyBytes), nil
}

// Turns net/http []*Cookie into a string for adding to the Cookie header.
//
// if resp.StatusCode == 302 {
Expand Down
Loading