Skip to content

Commit 7bbc033

Browse files
devploitclaude
andcommitted
feat: add input list, JSON output, unicode encoding, and custom payload positions
New features: - Input list support: -u flag now accepts a file containing URLs (one per line) (#37) - Output to file: -o flag saves results to a file; --json outputs in JSON format (#37) - Unicode encoding technique: overlong UTF-8 and %uXXXX encoded path bypass attempts (#47) - Custom payload positions: -p flag with markers in URL for targeted payload injection (#34) Bug fixes: - Fix HTTP/2.0 request file parsing: "HTTP/2.0" no longer becomes "HTTP/1.1.0" - Fix query string preservation in double-encoding, midpaths, and path-case-switching - Fix byte index bug in double-encoding for multi-byte runes - Fix overlong UTF-8 formula producing incorrect byte sequences Improvements: - Add 4 new bypass-relevant endpaths (?&, .., /., ;/) - Add unicode technique to default technique list (before http-versions) - Widen main banners (NOMORE403, AUTO-CALIBRATION) to 56 chars for visual hierarchy - Thread-safe JSON result accumulation with mutex - Comprehensive test coverage for all new features and bug fixes (29 tests passing) Closes #37, closes #47, closes #34 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ff03254 commit 7bbc033

8 files changed

Lines changed: 846 additions & 24 deletions

File tree

cmd/api.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,13 @@ func loadFlagsFromRequestFile(requestFile string, schema bool, verbose bool, tec
204204
return
205205
}
206206

207-
// Down HTTP/2 to HTTP/1.1
208-
firstLine := strings.Replace(temp[0], "HTTP/2", "HTTP/1.1", 1)
207+
// Down HTTP/2 to HTTP/1.1 (handles both "HTTP/2" and "HTTP/2.0")
208+
firstLine := temp[0]
209+
if strings.Contains(firstLine, "HTTP/2.0") {
210+
firstLine = strings.Replace(firstLine, "HTTP/2.0", "HTTP/1.1", 1)
211+
} else if strings.Contains(firstLine, "HTTP/2") {
212+
firstLine = strings.Replace(firstLine, "HTTP/2", "HTTP/1.1", 1)
213+
}
209214
content = []byte(strings.Join(append([]string{firstLine}, temp[1:]...), "\n"))
210215

211216
reqReader := strings.NewReader(string(content))

cmd/bypass_test.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,20 @@ func resetTestState() {
7171
setVerbose(true) // Show all results in tests
7272
setDefaultCl(0)
7373
setCalibTolerance(0)
74+
setDefaultSc(0)
75+
setDefaultRespCl(0)
7476
maxGoroutines = 5
7577
delay = 0
7678
redirect = false
7779
statusCodes = nil
7880
uniqueOutput = false
7981
nobanner = true
82+
jsonOutput = false
83+
payloadPosition = ""
84+
outputWriter = nil
85+
jsonResultsMutex.Lock()
86+
jsonResults = nil
87+
jsonResultsMutex.Unlock()
8088
resetMaps()
8189
}
8290

@@ -645,6 +653,212 @@ func TestRequestFilePreservesQueryString(t *testing.T) {
645653
}
646654
}
647655

656+
func TestRequestFileParsesPostWithBody(t *testing.T) {
657+
resetTestState()
658+
659+
var mu sync.Mutex
660+
var capturedMethods []string
661+
var capturedURIs []string
662+
var capturedHeaders []http.Header
663+
664+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
665+
mu.Lock()
666+
capturedMethods = append(capturedMethods, r.Method)
667+
capturedURIs = append(capturedURIs, r.URL.RequestURI())
668+
capturedHeaders = append(capturedHeaders, r.Header.Clone())
669+
mu.Unlock()
670+
w.WriteHeader(http.StatusForbidden)
671+
fmt.Fprint(w, "Forbidden")
672+
}))
673+
defer ts.Close()
674+
675+
// Simulate a Burp-style POST request with HTTP/2 and body
676+
rawRequest := fmt.Sprintf("POST /api/v1/data HTTP/2\r\nHost: %s\r\nContent-Type: application/json\r\nX-Custom: test-value\r\n\r\n{\"key\":\"value\"}",
677+
strings.TrimPrefix(ts.URL, "http://"))
678+
679+
dir := t.TempDir()
680+
reqFile := filepath.Join(dir, "request.txt")
681+
if err := os.WriteFile(reqFile, []byte(rawRequest), 0o600); err != nil {
682+
t.Fatalf("write request file: %v", err)
683+
}
684+
685+
payloadsDir := setupPayloadsDir(t)
686+
folder = payloadsDir
687+
nobanner = true
688+
689+
loadFlagsFromRequestFile(reqFile, true, true, []string{"verbs"}, false)
690+
691+
mu.Lock()
692+
defer mu.Unlock()
693+
694+
if len(capturedMethods) == 0 {
695+
t.Fatal("expected requests from request file, got 0")
696+
}
697+
698+
// Default request should use the original POST method
699+
foundPost := false
700+
for _, m := range capturedMethods {
701+
if m == "POST" {
702+
foundPost = true
703+
break
704+
}
705+
}
706+
if !foundPost {
707+
t.Errorf("expected POST method to be used, got methods: %v", capturedMethods)
708+
}
709+
710+
// URI should be correctly parsed
711+
foundURI := false
712+
for _, uri := range capturedURIs {
713+
if strings.Contains(uri, "/api/v1/data") {
714+
foundURI = true
715+
break
716+
}
717+
}
718+
if !foundURI {
719+
t.Errorf("expected /api/v1/data in URIs, got: %v", capturedURIs)
720+
}
721+
722+
// Custom headers should be extracted
723+
foundCustomHeader := false
724+
for _, h := range capturedHeaders {
725+
if h.Get("X-Custom") == "test-value" {
726+
foundCustomHeader = true
727+
break
728+
}
729+
}
730+
if !foundCustomHeader {
731+
t.Errorf("expected X-Custom header, not found in captured headers")
732+
}
733+
}
734+
735+
func TestRequestFileHandlesHTTP20(t *testing.T) {
736+
resetTestState()
737+
738+
var mu sync.Mutex
739+
var capturedURIs []string
740+
741+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
742+
mu.Lock()
743+
capturedURIs = append(capturedURIs, r.URL.RequestURI())
744+
mu.Unlock()
745+
w.WriteHeader(http.StatusForbidden)
746+
fmt.Fprint(w, "Forbidden")
747+
}))
748+
defer ts.Close()
749+
750+
// Test with "HTTP/2.0" (another common Burp format)
751+
rawRequest := fmt.Sprintf("GET /admin/panel HTTP/2.0\r\nHost: %s\r\n\r\n",
752+
strings.TrimPrefix(ts.URL, "http://"))
753+
754+
dir := t.TempDir()
755+
reqFile := filepath.Join(dir, "request.txt")
756+
if err := os.WriteFile(reqFile, []byte(rawRequest), 0o600); err != nil {
757+
t.Fatalf("write request file: %v", err)
758+
}
759+
760+
payloadsDir := setupPayloadsDir(t)
761+
folder = payloadsDir
762+
nobanner = true
763+
764+
loadFlagsFromRequestFile(reqFile, true, true, []string{"verbs"}, false)
765+
766+
mu.Lock()
767+
defer mu.Unlock()
768+
769+
if len(capturedURIs) == 0 {
770+
t.Fatal("expected requests from HTTP/2.0 request file, got 0")
771+
}
772+
773+
foundURI := false
774+
for _, uri := range capturedURIs {
775+
if strings.Contains(uri, "/admin/panel") {
776+
foundURI = true
777+
break
778+
}
779+
}
780+
if !foundURI {
781+
t.Errorf("expected /admin/panel in URIs, got: %v", capturedURIs)
782+
}
783+
}
784+
785+
func TestPayloadPositionsSendsRequests(t *testing.T) {
786+
resetTestState()
787+
788+
ts, getRequests := testServer(t, nil)
789+
defer ts.Close()
790+
791+
payloadsDir := setupPayloadsDir(t)
792+
793+
opts := RequestOptions{
794+
uri: ts.URL + "/100/admin",
795+
headers: []header{{"User-Agent", "test"}},
796+
method: "GET",
797+
proxy: &url.URL{},
798+
folder: payloadsDir,
799+
timeout: 5000,
800+
rateLimit: false,
801+
redirect: false,
802+
verbose: true,
803+
payloadPositions: []string{"100"},
804+
uriTemplate: ts.URL + "/" + payloadPlaceholder(0) + "/admin",
805+
}
806+
807+
requestPayloadPositions(opts)
808+
809+
reqs := getRequests()
810+
if len(reqs) == 0 {
811+
t.Fatal("expected payload position requests to be sent, got 0")
812+
}
813+
814+
// Check that some requests replace "100" with payloads
815+
foundModified := false
816+
for _, r := range reqs {
817+
path := r.URL.Path
818+
if !strings.Contains(path, "/100/") && strings.Contains(path, "admin") {
819+
foundModified = true
820+
break
821+
}
822+
}
823+
if !foundModified {
824+
paths := make([]string, 0, len(reqs))
825+
for _, r := range reqs {
826+
paths = append(paths, r.URL.RequestURI())
827+
}
828+
t.Errorf("expected modified position paths, got: %v", paths)
829+
}
830+
}
831+
832+
func TestUnicodeEncodingSendsEncodedPaths(t *testing.T) {
833+
resetTestState()
834+
835+
ts, getRequests := testServer(t, nil)
836+
defer ts.Close()
837+
838+
opts := RequestOptions{
839+
uri: ts.URL + "/admin",
840+
headers: []header{{"User-Agent", "test"}},
841+
method: "GET",
842+
proxy: &url.URL{},
843+
timeout: 5000,
844+
rateLimit: false,
845+
redirect: false,
846+
verbose: true,
847+
}
848+
849+
requestUnicodeEncoding(opts)
850+
851+
reqs := getRequests()
852+
if len(reqs) == 0 {
853+
t.Fatal("expected unicode encoding requests to be sent, got 0")
854+
}
855+
856+
// Should have multiple requests: overlong slash replacements + per-char encoding
857+
if len(reqs) < 5 {
858+
t.Errorf("expected at least 5 unicode encoding requests, got %d", len(reqs))
859+
}
860+
}
861+
648862
func TestVerbTamperingPreservesQueryString(t *testing.T) {
649863
resetTestState()
650864

cmd/output.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"sync"
8+
)
9+
10+
// JSONResult represents a single result in JSON output format.
11+
type JSONResult struct {
12+
StatusCode int `json:"status_code"`
13+
ContentLength int `json:"content_length"`
14+
Technique string `json:"technique"`
15+
Payload string `json:"payload"`
16+
}
17+
18+
var (
19+
outputWriter *os.File
20+
jsonResults []JSONResult
21+
jsonResultsMutex sync.Mutex
22+
)
23+
24+
// initOutputWriter opens the output file for writing.
25+
func initOutputWriter(path string) error {
26+
f, err := os.Create(path)
27+
if err != nil {
28+
return err
29+
}
30+
outputWriter = f
31+
return nil
32+
}
33+
34+
// closeOutputWriter flushes and closes the output file.
35+
// If JSON mode is enabled, writes the accumulated JSON results.
36+
func closeOutputWriter() {
37+
if outputWriter == nil {
38+
return
39+
}
40+
41+
if jsonOutput {
42+
jsonResultsMutex.Lock()
43+
data, err := json.MarshalIndent(jsonResults, "", " ")
44+
jsonResultsMutex.Unlock()
45+
if err != nil {
46+
fmt.Fprintf(os.Stderr, "[!] Error marshaling JSON: %v\n", err)
47+
} else {
48+
outputWriter.Write(data)
49+
outputWriter.Write([]byte("\n"))
50+
}
51+
}
52+
53+
outputWriter.Close()
54+
outputWriter = nil
55+
}
56+
57+
// writeResultToOutput writes a result to the output file.
58+
// In JSON mode, it accumulates results (thread-safe via jsonResultsMutex).
59+
// In plain mode, it writes immediately — caller MUST hold printMutex.
60+
func writeResultToOutput(result Result, technique string) {
61+
if outputWriter == nil && !jsonOutput {
62+
return
63+
}
64+
65+
if jsonOutput {
66+
jsonResultsMutex.Lock()
67+
jsonResults = append(jsonResults, JSONResult{
68+
StatusCode: result.statusCode,
69+
ContentLength: result.contentLength,
70+
Technique: technique,
71+
Payload: result.line,
72+
})
73+
jsonResultsMutex.Unlock()
74+
75+
// If no output file, write JSON to stdout at close
76+
return
77+
}
78+
79+
if outputWriter != nil {
80+
fmt.Fprintf(outputWriter, "%d\t%d bytes\t%s\n", result.statusCode, result.contentLength, result.line)
81+
}
82+
}
83+
84+
// flushJSONToStdout writes JSON results to stdout when no output file is specified.
85+
func flushJSONToStdout() {
86+
if !jsonOutput || outputWriter != nil {
87+
return
88+
}
89+
90+
jsonResultsMutex.Lock()
91+
defer jsonResultsMutex.Unlock()
92+
93+
if len(jsonResults) == 0 {
94+
return
95+
}
96+
97+
data, err := json.MarshalIndent(jsonResults, "", " ")
98+
if err != nil {
99+
fmt.Fprintf(os.Stderr, "[!] Error marshaling JSON: %v\n", err)
100+
return
101+
}
102+
fmt.Println(string(data))
103+
}

0 commit comments

Comments
 (0)