Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
27faf5c
fix: data race in the /metrics handler
nisay759 Jul 30, 2026
2d69507
storage: add upload metadata and eviction hook
nisay759 Jul 30, 2026
02f4e6b
server: add UploadStore for session-scoped file hosting
nisay759 Jul 30, 2026
9a1d7c5
server: wire upload options, flags and lifecycle
nisay759 Jul 30, 2026
3b79661
server: add /upload endpoint and capability advertisement
nisay759 Jul 30, 2026
fab656d
server: serve hosted files from /f/ with a body-elided interaction
nisay759 Jul 30, 2026
0d57c82
server: hide hosted files from FTP listings, attribute downloads
nisay759 Jul 30, 2026
b0a3832
client: add UploadFiles and server capability plumbing
nisay759 Jul 30, 2026
a3edfe9
client: add -file flag to host payload files
nisay759 Jul 30, 2026
a7caaee
Immediate upload cleanup on deregister, fix ftp URL port, update README
nisay759 Jul 30, 2026
d01e35a
client: release the session when an upload aborts startup
nisay759 Aug 4, 2026
9dc056c
client: name the server when an upload is refused
nisay759 Aug 5, 2026
efedfbb
docs: regenerate the -h blocks in the README from the binaries
nisay759 Aug 5, 2026
52eb6ea
docs: state that interactsh prunes a directory in the upload root
nisay759 Aug 6, 2026
d8cd700
server: stage uploads and commit the batch together
nisay759 Aug 6, 2026
933bc63
client: keep payload hostnames and hosted file URLs in separate files
nisay759 Aug 6, 2026
2f3abef
client: stop reading 404 as "uploads unsupported", name version skew
nisay759 Aug 6, 2026
e51be93
docs: match the hosted-file examples to what the binaries print
nisay759 Aug 6, 2026
3f58b9b
style: gofmt the two files this branch adds fields to
nisay759 Aug 6, 2026
fb9f1e5
examples: show the file-hosting capability in the client library example
nisay759 Aug 6, 2026
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
212 changes: 191 additions & 21 deletions README.md

Large diffs are not rendered by default.

124 changes: 123 additions & 1 deletion cmd/interactsh-client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
Expand Down Expand Up @@ -44,6 +45,11 @@ func main() {

flagSet.CreateGroup("input", "Input",
flagSet.StringVarP(&cliOptions.ServerURL, "server", "s", defaultOpts.ServerURL, "interactsh server(s) to use"),
// StringSliceOptions, not the FileCommaSeparated variant used by
// -match/-filter: that one reads the file and splits its contents,
// which for -file would turn a DTD into a list of names.
flagSet.StringSliceVarP(&cliOptions.Files, "file", "fl", nil,
"local file(s) to upload and host on the interactsh server", goflags.StringSliceOptions),
)

flagSet.CreateGroup("config", "config",
Expand Down Expand Up @@ -78,6 +84,7 @@ func main() {
flagSet.BoolVar(&cliOptions.JSON, "json", false, "write output in JSON Lines format"),
flagSet.BoolVarP(&cliOptions.StorePayload, "payload-store", "ps", false, "write generated interactsh payload to file"),
flagSet.StringVarP(&cliOptions.StorePayloadFile, "payload-store-file", "psf", settings.StorePayloadFileDefault, "store generated interactsh payloads to given file"),
flagSet.StringVarP(&cliOptions.FileStoreFile, "file-store-file", "fsf", "", "store hosted file URLs to given file (requires -file)"),

flagSet.BoolVar(&cliOptions.Verbose, "v", false, "display verbose interaction"),
)
Expand Down Expand Up @@ -171,6 +178,10 @@ func main() {
gologger.Fatal().Msgf("Could not create client: %s\n", err)
}

// Uploads must follow registration, since the server verifies the session,
// and precede the payload listing so every URL is shown together.
fileURLs := uploadFiles(client, cliOptions)

interactshURLs := generatePayloadURL(cliOptions.NumberOfPayloads, client)

gologger.Info().Msgf("Listing %d payload for OOB Testing\n", cliOptions.NumberOfPayloads)
Expand All @@ -180,11 +191,31 @@ func main() {

warnIfServerLacksIPv6(client)

if len(fileURLs) > 0 {
gologger.Info().Msgf("Hosting %d file(s) for OOB Testing\n", len(cliOptions.Files))
for _, fileURL := range fileURLs {
gologger.Info().Msgf("%s\n", fileURL)
}
}

// One record type per file. -psf is a machine-readable list of payload
// hostnames, one per line and exactly -n of them, which is what a wrapper
// script substituting into a payload template relies on; mixing hosted-file
// URLs into it turns "$line" into "https://host/f/x" and silently produces
// nonsense like http://https://host/f/x/. Hosted-file URLs get their own file.
if cliOptions.StorePayload && cliOptions.StorePayloadFile != "" {
if err := os.WriteFile(cliOptions.StorePayloadFile, []byte(strings.Join(interactshURLs, "\n")), 0644); err != nil {
if err := writeLines(cliOptions.StorePayloadFile, interactshURLs); err != nil {
gologger.Fatal().Msgf("Could not write to payload output file: %s\n", err)
}
}
if cliOptions.FileStoreFile != "" {
if len(fileURLs) == 0 {
gologger.Warning().Msgf("-file-store-file was given without -file, so no hosted file URLs were written\n")
}
if err := writeLines(cliOptions.FileStoreFile, fileURLs); err != nil {
gologger.Fatal().Msgf("Could not write to file URL output file: %s\n", err)
}
}

// show all interactions
noFilter := !cliOptions.DNSOnly && !cliOptions.HTTPOnly && !cliOptions.SmtpOnly
Expand Down Expand Up @@ -320,6 +351,83 @@ func generatePayloadURL(numberOfPayloads int, client *client.Client) []string {
return interactshURLs
}

// electionHint explains which server the complaint is about when -s named more
// than one. The client registers with a single server chosen at random, and
// upload support cannot influence that choice because it is only advertised in
// the registration response. Without this, a mixed list reads as "none of my
// servers support uploads" on the runs that happen to elect one that does not.
func electionHint(serverList string) string {
var listed int
for _, s := range strings.Split(serverList, ",") {
if strings.TrimSpace(s) != "" {
listed++
}
}
if listed < 2 {
return ""
}
return fmt.Sprintf(" (chosen at random from the %d servers in -s, so this may differ between runs;"+
" pass a single server with -file)", listed)
}

// uploadFiles hosts local files on the interactsh server and returns the URLs a
// target should fetch. It returns nil when no files were requested.
func uploadFiles(c *client.Client, cliOptions *options.CLIClientOptions) []string {
if len(cliOptions.Files) == 0 {
return nil
}

uploaded, err := c.UploadFiles(cliOptions.Files)
if err != nil {
// Registration already happened -- it has to, since upload support is
// only advertised in the register response -- so a session exists on the
// server. Wind it down the same way the signal handler does, rather than
// leaving it to sit until the eviction TTL: persist it if the user asked
// for a resumable session, otherwise deregister it. Doing neither would
// overstate the server's live session count for every client that trips
// this path, and would strand a session the user cannot resume.
if cliOptions.SessionFile != "" {
_ = c.SaveSessionTo(cliOptions.SessionFile)
} else {
_ = c.Close()
}
// Name the server in both failures. The client registers with one server
// out of -s, so without it the reader cannot tell which of their servers
// the complaint is about.
if errors.Is(err, client.ErrUploadNotAdvertised) {
gologger.Fatal().Msgf("Server %s did not advertise file hosting, so it predates -file; upgrade the server%s\n",
c.ServerURL(), electionHint(cliOptions.ServerURL))
}
if errors.Is(err, client.ErrUploadUnsupported) {
gologger.Fatal().Msgf("Server %s does not accept file uploads; it must be started with -upload%s\n",
c.ServerURL(), electionHint(cliOptions.ServerURL))
}
// Fatal rather than a warning: the user asked to host a payload, and
// carrying on without it produces a confusing "no interaction" result.
gologger.Fatal().Msgf("Could not upload files to %s: %s\n", c.ServerURL(), err)
}

// One payload host for every file, so the target performs a single DNS
// lookup and the output is consistent. Any nonce works, since the server
// only reads the correlation id prefix.
host := c.URL()
withFTP := false
if caps := c.Capabilities(); caps != nil {
withFTP = caps.FTP
}

var urls []string
for _, file := range uploaded {
urls = append(urls, c.FileURL(host, file))
// Only when the server actually runs an FTP listener, otherwise the
// URL would never connect.
if withFTP {
urls = append(urls, c.FTPFileURL(host, file))
}
}
return urls
}

func writeOutput(outputFile *os.File, builder *bytes.Buffer) {
if outputFile != nil {
_, _ = outputFile.Write(builder.Bytes())
Expand Down Expand Up @@ -352,3 +460,17 @@ func (m *regexMatcher) match(item string) bool {
}
return false
}

// writeLines writes one record per line, newline-terminated.
//
// The terminator matters: without it the last record has no newline, so a plain
// "while read line" loop -- the most likely consumer of these files -- drops it,
// and wc -l reports one fewer record than the file holds.
func writeLines(path string, lines []string) error {
var b strings.Builder
for _, line := range lines {
b.WriteString(line)
b.WriteString("\n")
}
return os.WriteFile(path, []byte(b.String()), 0644)
}
91 changes: 91 additions & 0 deletions cmd/interactsh-client/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package main

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/require"
)

func TestElectionHint(t *testing.T) {
t.Run("stays silent for a single server", func(t *testing.T) {
// Nothing to disambiguate: the message already names the only server.
require.Empty(t, electionHint("https://oast.pro"))
require.Empty(t, electionHint(""))
// A trailing comma still describes one server.
require.Empty(t, electionHint("https://oast.pro,"))
})

t.Run("names the count when several were listed", func(t *testing.T) {
hint := electionHint("oast.pro,oast.live,oast.site")
require.Contains(t, hint, "3 servers")
require.Contains(t, hint, "pass a single server with -file",
"the hint must say what to do, not just what happened")
})

t.Run("ignores blank entries and whitespace", func(t *testing.T) {
require.Empty(t, electionHint(" , "))
require.Contains(t, electionHint("oast.pro, oast.live"), "2 servers")
})

t.Run("reads as a suffix to the failure sentence", func(t *testing.T) {
hint := electionHint("a.example,b.example")
require.True(t, strings.HasPrefix(hint, " ("), "must append cleanly after the message")
require.True(t, strings.HasSuffix(hint, ")"))
})
}

// -psf is a machine interface: one payload hostname per line, exactly -n of them.
// A consumer substitutes each line into a payload template, so a line carrying a
// full URL produces nonsense, and a missing trailing newline costs it the last
// record.
func TestWriteLines(t *testing.T) {
t.Run("one record per line, newline terminated", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "payloads.txt")
payloads := []string{
"c6rj61aciaeutn2ae680ti6cc3rxeenc3.oast.pro",
"c6rj61aciaeutn2ae680xk4tqy8pqhwmi.oast.pro",
}
require.NoError(t, writeLines(path, payloads))

raw, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, strings.Join(payloads, "\n")+"\n", string(raw))

// What "while read" and "wc -l" see, which is the point of the terminator.
require.Equal(t, len(payloads), strings.Count(string(raw), "\n"))
require.Equal(t, payloads, strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n"))
})

t.Run("no record is a valid empty file", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.txt")
require.NoError(t, writeLines(path, nil))
raw, err := os.ReadFile(path)
require.NoError(t, err)
require.Empty(t, raw, "an empty list must not leave a stray newline")
})

t.Run("hosted file URLs stay in their own file", func(t *testing.T) {
dir := t.TempDir()
payloads := []string{"c6rj61aciaeutn2ae680ti6cc3rxeenc3.oast.pro"}
fileURLs := []string{
"https://c6rj61aciaeutn2ae680xk4tqy8pqhwmi.oast.pro/f/evil.dtd",
"ftp://c6rj61aciaeutn2ae680xk4tqy8pqhwmi.oast.pro/.interactsh-user-uploads/c6rj61aciaeutn2ae680/evil.dtd",
}
payloadFile := filepath.Join(dir, "payloads.txt")
urlFile := filepath.Join(dir, "files.txt")
require.NoError(t, writeLines(payloadFile, payloads))
require.NoError(t, writeLines(urlFile, fileURLs))

gotPayloads, err := os.ReadFile(payloadFile)
require.NoError(t, err)
require.NotContains(t, string(gotPayloads), "://",
"a payload file line must be a hostname, never a URL")

gotURLs, err := os.ReadFile(urlFile)
require.NoError(t, err)
require.Equal(t, strings.Join(fileURLs, "\n")+"\n", string(gotURLs))
})
}
Loading