DedSec Security Research ·
Here is a hypothetical. A dynamic scan of a staging environment returns three findings, all on query parameters. The first, titled "OS command injection", is on /diagnostics/ping?host=. The scanner sent 127.0.0.1;echo qzxv and the response body contained qzxv. The second, also titled "OS command injection", is on /webhooks/test?url=. A payload containing a unique callback hostname produced a DNS lookup for that hostname at the scanner's out-of-band server, and nothing else. The third is on /repos/import?source=, titled "possible argument injection" and rated low. A value of --version changed the response, and nothing more happened.
The developer who picks up the first one calls it critical, because the marker came back. The second is triaged as critical too, because a callback is a callback. The third is closed, because the import code calls git through Python's subprocess with a list and shell=False, and everyone knows that means no shell and no injection. All three calls are probably wrong. As with the SQL injection findings in the previous post in this series, nobody asked what the scanner actually observed, or what that observation can prove.
This post is for whoever is holding a command injection finding from a dynamic scan. It covers what OS command injection and argument injection are and why the difference decides the fix, how the quoting context and the shell decide which payloads work, what each kind of detection evidence shows and how each produces false positives, why code injection through eval is a separate class, why asynchronous sinks are invisible to request-and-response testing, how to test without damaging production, and what the fix is in Python, Node.js, Java and Go. It is the third post in a series, after reflected XSS and SQL injection. It builds on out-of-band testing, DAST coverage, what a SAST false positive is in mechanism and script injection in GitHub Actions, which is the same bug inside a CI runner.
Sources: the OWASP OS Command Injection Defense Cheat Sheet; the OWASP Web Security Testing Guide's command injection test (WSTG-INJT-12); CWE-78, CWE-88, CWE-94 and CWE-95; the Commix README and its wiki's Home and Techniques pages; the "Security Considerations" section of Python's subprocess documentation and the shlex.quote documentation; the Node.js child_process documentation and deprecation DEP0190; the Java SE 21 documentation for ProcessBuilder and Runtime.exec; the package comment of Go's os/exec; PHP's escapeshellarg page; Microsoft's about_Pipeline_Chain_Operators page for PowerShell; and the POSIX.1-2024 Shell Command Language (section 2.2, Quoting) with its Utility Syntax Guidelines. All were retrieved on 30 October 2026. The shell, Python, Node.js, git and curl behaviour described below was measured the same day in a scratch directory on an Ubuntu 24.04 machine, with no network access used: GNU bash 5.2.21, dash 0.5.12 (Ubuntu's /bin/sh), Python 3.12.3, Node.js 22.22.2, git 2.43.0, curl 8.5.0, GNU coreutils 9.4 and iputils ping 20240117. Java, Go, PHP, cmd.exe and PowerShell were not available, so what this post says about them comes from their documentation and was not measured. The opening scenario is hypothetical, and no number in this post measures any scanner, ours included.
CWE-78 describes a product that "constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command before it is sent to a downstream component". The special elements are the shell's: separators, substitutions, redirections and quotes. The claim a finding has to support is that the input changed which commands ran, not just the value one command received.
CWE-88, argument injection, is the neighbouring weakness. The product builds a command for another component "but it does not properly delimit the intended arguments, options, or switches within that command string". No shell is needed for this one. If a value that starts with a dash lands where a program reads options, the program reads it as an option. The OWASP cheat sheet puts the relationship in one line: "Every OS Command Injection is also an Argument Injection." The reverse is not true, and that asymmetry is what most of the fix section below is about. A command injection is fixed by keeping the shell out of the path. An argument injection survives that fix, because it never needed the shell.
CWE-78's own notes describe two shapes of the bug. In the first, the application runs a fixed program and passes user input as an argument, as in nslookup [HOSTNAME]. The attacker cannot stop nslookup running, but a separator in the hostname adds a second command after it. In the second, the input selects the whole command. The entry also notes that when the command runs through functions like exec() and CreateProcess(), without a shell, "the attacker might not be able to combine multiple commands together in the same line". The first shape is the usual DAST finding. The second is mostly found in code review.
A dynamic scanner sees neither the command line nor the shell. It sees requests, responses, the time between them, and any traffic that arrives at a server it controls. Every technique below infers execution from one of those four things.
The XSS post argued that output context decides which characters matter, and the SQL injection post made the same argument about query context. Shell quoting is the same idea, and the POSIX standard defines it precisely. Single quotes "shall preserve the literal value of each character within the single-quotes. A single-quote cannot occur within single-quotes." Double quotes preserve the literal value of everything except "backquote, <dollar-sign>, and <backslash>", and the dollar sign keeps its meaning for parameter expansion, command substitution and arithmetic expansion.
We measured what that means for a payload. The test program built a shell string the way vulnerable code does, with the value placed in a printf '[%s]\n' command three ways: unquoted, inside single quotes and inside double quotes. It ran each string under dash and under bash. Each payload carried a marker that only a shell could compute, INJ$((6*7)), so execution shows up as INJ42 and a mere echo of the input shows up as the literal text. Both shells gave the same results.
a;echo INJ$((6*7)), a&&echo INJ$((6*7)), a newline followed by echo INJ$((6*7)), a$(echo INJ$((6*7))) and the same substitution written with backticks all executed. a|echo INJ$((6*7)) executed, and the original output disappeared from the result, because the pipe fed it into echo, which ignores its input. a||echo INJ$((6*7)) did not execute, because || only runs the second command when the first fails, and printf succeeded. An || payload against a command that succeeds shows nothing, which is why testers often put an invalid value in front of it.a';echo INJ$((6*7));'. It executed, and the leftover pair of quotes became a command whose name is an empty string, which the shell could not find, so the whole command line exited with status 127. The breakout has to leave the quotes balanced, or the shell rejects the line and nothing runs. Ending it with a comment, as in a';echo INJ$((6*7)) #, avoids the error. That matters for an application that only shows output when the command succeeds.$(...) and backticks executed inside the quotes, exactly as POSIX says they should. The less obvious result: a;echo INJ$((6*7)) came back as [a;echo INJ42]. The semicolon did nothing, because it was inside quotes, but the arithmetic expansion still ran. The computed marker proves that a shell evaluated the input. It does not prove that the separator the scanner used was the one that worked, and a report that says "injectable via ;" can be wrong about the mechanism while being right about the finding.Two practical consequences follow. A payload has to be valid syntax in the target shell. a;sleep(1), written as a function call, was a syntax error in both dash and bash, and because the shell parses the whole line before running any of it, the original command did not run either. And a scanner that only tries unquoted separators will miss a parameter that lands inside quotes, while one that only tries $(...) will miss one inside single quotes. A scanner has to try each context's closing sequence to cover all three.
We had no Windows shell to measure, so this part comes from the documentation. The WSTG lists |, ||, && and & as working on both Windows and Unix-like systems, and says the semicolon "will work on Unix-based systems and PowerShell. However, it will not work on Windows Command Prompt (CMD)." The Commix Techniques page says cmd.exe "chains commands on &, &&, | and || only, expands no arithmetic inside an argument, prints quote characters literally, has no sleep and no comment character". It also explains the consequence for its own payloads: in cmd.exe a ; or a newline becomes another argument to the target's own command, so those separators are not tried against a Windows target at all. The WSTG's list fits cmd.exe, not PowerShell. Microsoft's about_Pipeline_Chain_Operators page says "Beginning in PowerShell 7, PowerShell implements the && and || operators", so they are not available in Windows PowerShell 5.1, and in PowerShell & is the call operator, not a separator.
Two things follow for triage. First, a computed marker like $((6*7)) means nothing to cmd.exe, so a Unix-style results-based payload fails against a Windows target even when the parameter is injectable. The Commix documentation shows its Windows payloads computing the value with set /a inside a for /f loop instead. Second, a single & means something different in each shell: in cmd.exe it chains commands, and in a POSIX shell it puts the first command in the background. A finding should say which shell the evidence implies. If the report shows a POSIX payload and the host runs Windows, look again at what was observed.
The strongest and simplest evidence is command output in the response. The weakest version of it is a literal marker. If the scanner sends 127.0.0.1;echo qzxv and the response contains qzxv, there are two explanations. A shell ran echo, or the application printed the parameter back, as in "Results for 127.0.0.1;echo qzxv". The second explanation is common: diagnostics pages, error messages and search pages all reflect input. A literal string the scanner chose proves only that the string came back, which is the XSS post's subject, not this one.
The fix is to ask for something only an evaluator could produce. If the response contains INJ42 and the request contained INJ$((6*7)), a reflection cannot explain it, because the reflected text would still read $((6*7)). That is what our single-quoted test showed: the literal payload came back intact, and INJ42 did not appear. Commix's documented results-based payload for Unix-like targets is built on the same principle, combining an arithmetic expansion and a command substitution between marker strings. Its documentation on boolean detection says it plainly: detection "asks the target to print something unguessable and measure it, so a page that merely differs between two requests is not mistaken for one that ran a command."
Choose the expression syntax with care, because other evaluators compute arithmetic too. Python's eval returned 42 for 6*7, as would many server-side template engines given {{6*7}}. But eval of $((6*7)) raised a SyntaxError in Python and a ReferenceError in Node.js. Shell arithmetic syntax that comes back computed points to a shell. A bare 42 in the response points to some evaluator, which might be a server-side template or an eval sink, both of them different findings with different fixes.
Two more checks before calling a results-based finding confirmed. Repeat it with a different expression, such as $((7*9)), and check that the response changes to match. And check where the marker appears. Output inside the region where the page renders the command's result is stronger than a marker in an error message that also contains the request.
When output is not shown but the page differs depending on whether a command succeeded, as with "Host is up" and "Host is down", the difference is a one-bit oracle. The Commix Techniques page documents a boolean-based technique built on this, which calibrates true and false pages from two questions with known answers. It also notes that the separator matters: with ; the exit status is the last command's, so anything the application appends after the injection point can discard the test's result, while && survives that. The confirmation logic is the same as for SQL injection: a baseline, a true variant and a false variant, two different phrasings of each, and a check against the noise between identical requests.
When nothing in the page changes, the remaining signal is time. Inject sleep on Unix-like hosts, or ping against the loopback address, and measure. We timed the primitives on an idle machine to see how much noise the command itself contributes.
sh -c "sleep N" took N seconds plus 2 to 6 milliseconds, for N of 1, 2 and 3, with five runs each and a spread between runs of about a millisecond.ping -c N 127.0.0.1 took about N−1 seconds: 1.02 seconds for two packets and 3.07 for four, because ping waits one second between packets and does not wait after the last reply. A single packet returned in about 5 milliseconds. The Commix documentation says the same of ping -n N 127.0.0.1 on Windows, which it uses because cmd.exe has no sleep. It adds that a PowerShell launch "costs a second or two of its own and varies from call to call".So the delay a payload asks for is the delay the command produces, to within milliseconds. The noise in a timing finding comes from everything around the command: the network, the application server, worker pools, caches, rate limiters, and anything else the target is doing. A single slow response is not a finding. The SQL injection post's list of controls applies without change: repeated requests interleaved with controls, graded delays (0, 2, 4 and 6 seconds) whose response times rise in step, and a conditional version whose delay follows the condition. Commix's documented time-based payload is conditional in exactly that sense. It multiplies the delay by a comparison inside shell arithmetic, as in sleep $((5*(6==$RYU1))), so the response is slow only when the comparison holds.
Three ways timing misleads that are specific to command injection:
;sleep 2 payload took 2.00 seconds on the synchronous route and 1 millisecond on the queued route. On the queued route, the worker's log showed the sleep ran and an echo INJ$((6*7)) payload produced INJ42. A time-based scanner would have reported that route clean, and it was executing every payload.;sleep 2 payload waited 1.8 seconds for its response. Real application servers have more workers, but a scan that sends delay payloads to many parameters at once holds many of them.When neither output nor timing is available, a payload can redirect output to a file and the tester then fetches it. The WSTG describes redirecting "the output of the injected command in the web server's web root" and requesting the file. The Commix documentation lists a file-based technique that does exactly this, plus a variant that writes to a temporary directory for targets where the web root is not writable, and it notes that at the end of a run it offers to delete the file and names its full path.
The evidence is strong: a file appeared that only the payload could have created. The method is not acceptable against production. It writes to the filesystem of a live server, possibly into a directory that is served to the public, and a crash or an interrupted scan leaves the file behind. File-based confirmation belongs on a staging copy, with explicit authorisation. Against production, prefer the out-of-band channel below.
An out-of-band payload makes the target contact a server the tester controls, usually through an Interactsh-style service with a unique hostname per payload. Our out-of-band post covers how the callbacks are correlated, and why a DNS-only interaction is weaker than an HTTP one: the DNS query usually arrives from a resolver, not the target, and egress filtering often allows DNS while blocking HTTP. Three points are specific to command injection.
QOMDZR9902QOMDZR; anything replaying the URL verbatim sends the expression instead." The same principle as the results-based marker, carried over HTTP.--oob uses the public oast.fun interactsh server, so interaction metadata for your target leaves your network", and that a self-hosted server keeps it in house. The same is true of any OAST setup.Silence is still not a clean result. A host with no outbound DNS or HTTP never calls back, however injectable the parameter is.
Commix is an open-source tool built for this class, and the WSTG lists it among its tools. Its documentation separates two choices that a finding report can easily merge. As retrieved on 30 October 2026, the README and wiki list four injection techniques, chosen with --technique: results-based (classic), boolean-based, time-based and file-based, the last with the temporary-directory variant described above. Out-of-band is not one of them. It runs alongside whichever techniques were chosen, through a separate --oob switch.
The second choice is what is injected into: a shell, by default, or a string the target evaluates as code. The documentation is emphatic that "Code injection is not command injection", and it does not test for code injection unless asked with --eval. The README lists PHP, Python, Ruby, JavaScript, PowerShell, Perl and Lua for that option. Commix's own model, then, is a sink crossed with a proof technique, and a report should say both.
The documentation also describes a final check for blind findings. Each blind technique proves itself by a yes or no read off a page, a clock, a file or a callback, and the documentation says any of those can occasionally answer yes by coincidence, such as "a slow backend read as a deliberate delay". So, when every positive technique for a parameter is a blind one, Commix runs a short series of known true and false questions against the injection point. A parameter that fails is dropped back to not injectable. Whatever tool raised your finding, it is worth knowing whether its blind results went through any check of that kind.
CWE-95, eval injection, describes a product that "does not neutralize or incorrectly neutralizes code syntax before using the input in a dynamic evaluation call (e.g. "eval")". It sits under CWE-94, code injection. The input runs inside the application's own interpreter: PHP's eval, Python's eval or exec, JavaScript's eval or Function, and similar.
Three things change. The payload needs no shell separators, because it is code in the host language. The impact starts inside the application process, with access to its memory, its configuration and its credentials, before any operating system command runs. And the fix is different. There is no argument-vector API for eval, and no escaping function that makes evaluating untrusted input safe. The fix is to stop evaluating input: parse it as data with a format parser such as JSON, look it up in a table of allowed operations, or write an interpreter for the small expression language you actually need. A finding raised by an eval-mode test, or one whose evidence is a host-language expression rather than shell syntax, should be filed as CWE-94 or CWE-95, not CWE-78.
This is the third hypothetical finding. The import code ran git with Python's subprocess, passing a list, without a shell. We reproduced the shape locally against a throwaway repository: the program ['git', 'ls-remote', source, 'repo'], with source taken from the request.
source set to --upload-pack=touch GITMARK; git-upload-pack, git read the value as an option, and the marker file appeared. The application never called a shell. git did, because git runs the upload-pack program it was given through a shell, which is why the semicolon in the value worked. One argument, passed safely as one list element, was enough.-- placed before source, git refused the value with fatal: strange pathname ... blocked, and no file appeared.-- and the operand ext::sh -c touch% GITMARK, which asks for git's command-running transport, git 2.43.0 refused with fatal: transport 'ext' not allowed. That was git's default protocol policy acting, not the --. A value that is passed as an operand is still interpreted by the program.The same thing happened with programs that need no shell at all. ls -l given -aR produced a recursive listing of the whole directory. This is CWE-88's own example, and the fix it suggests, ls -l --, worked: with --, ls looked for a file called -aR. curl given --version printed its version instead of fetching anything. These are harmless options, chosen for the measurement. The real risk is options that write files, read configuration or run programs, which is the territory CWE-88's observed examples cover, including a dependency tool that generated git commands safely against shell injection but "allows injection of optional arguments with input beginning with a dash".
Python's shlex.quote returned -aR and --version unchanged, because neither contains anything special to a shell. When we built 'ls -l ' + shlex.quote('-aR') and ran it with shell=True, the recursive listing came back. Quoting turns the value into exactly one shell word, and one word is all an option needs. The OWASP cheat sheet says the same about PHP's escapeshellarg: "Even though escapeshellarg() prevents OS Command Injection, an attacker can still pass a single argument to the command." It also describes escapeshellcmd as weaker still, because it lets the user pass any number of extra arguments. We could not measure PHP on this machine.
-- does not helpPOSIX's Utility Syntax Guideline 10 says that after the first --, "Any following arguments should be treated as operands, even if they begin with the '-' character." It is a guideline, and it only covers options.
-- does not prevent additional transfers". We confirmed it with local file:// URLs. curl -s -- URL1 URL2 fetched both. A single operand containing {a,b} fetched two files, because of curl's URL globbing. With --globoff, the braces stayed literal and nothing was fetched. A value that contains a space, passed through a shell string, becomes two operands. And a URL operand is still a destination: -- does nothing about SSRF.-- -n when given -- -n, so it treated -- as text. Some programs have no end-of-options marker, and some parse their arguments themselves. Java's ProcessBuilder documentation notes that on some operating systems "programs are expected to tokenize command line strings themselves".--output <value> or -o <value>, the value belongs to an option, and -- placed later protects nothing. Allowlist the value.Many of the places where applications shell out are exactly where a request-and-response scanner sees nothing: job queues and background workers, scheduled processors that pick up uploaded files, image, video and document converters that call external programs, archive extraction, and anything that runs after the HTTP response has been sent. Our queue test showed the pattern. The response took a millisecond, it contained nothing, and the payload ran.
Three approaches reach these sinks. An out-of-band payload calls home whenever it runs, minutes or hours later, so long-lived per-payload correlation matters more than a fast poll. A tester who knows where the stored value is processed can look there, in a log, an output file or a status page. And static analysis can trace the value from the upload handler to the queue consumer to the call that starts the process, provided the rule set treats a queue message or a database read as a taint source. That is the second-order problem from the SQL injection post, and it is worth checking in whatever SAST you run. The DAST coverage post covers the wider point that a scanner's silence describes what it reached.
A command injection test runs commands on someone's server. General practice that follows:
The OWASP cheat sheet's first defence is the shortest: "The primary defense is to avoid calling OS commands directly." A library call that does the job, such as mkdir() instead of system("mkdir /dir_name") in its example, cannot be persuaded to run a second command. Where a program really must run, start it directly with an argument vector and no shell, then validate each argument.
Python. The subprocess documentation says the library "will not implicitly choose to call a system shell", and that with shell=True "it is the application's responsibility to ensure that all whitespace and metacharacters are quoted appropriately". In our test, subprocess.run(['printf', '[%s]\n', payload]) printed the payload literally, while the same command built as a string with shell=True executed it. A third form is a common mistake: a list combined with shell=True. The documentation says the first list item becomes the shell command and the rest become arguments to the shell itself. Our subprocess.run(['echo', payload], shell=True) printed an empty line. The payload became the shell's $0 and was never used, so the code is neither injectable nor working, and someone will eventually "fix" it by joining the list into a string. On Windows the documentation adds a caveat: batch files "may be launched by the operating system in a system shell regardless of the arguments passed to this library".
Node.js. The child_process documentation says exec spawns a shell and runs the command string in it, with the warning: "Never pass unsanitized user input to this function. Any input containing shell metacharacters may be used to trigger arbitrary command execution." execFile "does not spawn a shell by default", and neither does spawn. In Node.js 22.22.2, execSync with the payload in a template string executed it, and execFileSync and spawnSync with the payload as an array element printed it literally. Passing the same array to execFileSync with the shell option executed it. Deprecation DEP0190 explains why: with an args array and the shell option, "the values are not escaped, only space-separated, which can lead to shell injection." It became a runtime deprecation in Node.js 24. As with Python, .bat and .cmd files on Windows run through cmd.exe.
Java. We could not run Java here, so this is from the SE 21 documentation. ProcessBuilder takes the program and its arguments as a list. Runtime.exec(String) does not use a shell. It breaks the string into tokens with a StringTokenizer, and it has been deprecated since Java 18 with the note "This method is error-prone and should not be used", because "The command string is broken into tokens using only whitespace characters." The OWASP cheat sheet draws the security consequence: shell metacharacters are passed through as plain arguments, but a value containing a space becomes several arguments, which is argument injection. Use ProcessBuilder or exec(String[]), with one element per argument. new ProcessBuilder("sh", "-c", command) puts the shell back.
Go. The os/exec package comment says the package "intentionally does not invoke the system shell and does not expand any glob patterns or handle other expansions, pipelines, or redirections typically done by shells." exec.Command(name, args...) is an argument vector. exec.Command("sh", "-c", s) is a shell again, with all of the above. We did not measure Go.
Then constrain the arguments. An argument vector removes CWE-78, and leaves CWE-88. The cheat sheet's second layer is validation: the command from a fixed list, and each argument from an allowlist of values or an allowlist pattern with a length limit and no metacharacters. Its example pattern, ^[a-z0-9]{3,10}$, also cannot start with a dash, which is the property that matters for options. Hard-code the program and its options in code. Put -- before user-supplied operands where the program honours it, and then validate the operand for what it is: a path inside an allowed directory, a URL to an allowed host, a ref name that matches a pattern. As the cheat sheet puts it: "Argument separation does not replace argument validation."
Escaping is a weaker second choice. shlex.quote and escapeshellarg turn a value into one shell word, and that stops command injection when the escaping matches the shell. The shlex documentation warns that the module is "only designed for Unix shells", and the PHP documentation describes a different escaping on Windows. Neither does anything about argument injection, as measured above. If you are escaping, you are still calling a shell, and the first fix is still available.
Least privilege and containers limit damage. They do not fix it. A process that runs as an unprivileged user, in a container with a read-only filesystem, no capabilities and no egress, makes a command injection much less useful, and it can also stop the out-of-band channel that would have shown it to you. Do it, as a separate change, and do not record it as the fix. The pod security context post covers the Kubernetes side.
A WAF is a mitigation, not a fix. The WSTG's filter evasion section lists URL encoding, ${IFS} in place of spaces, brace expansion, quote insertion such as wh'o'ami, case changes and base64 decoding. Each is a payload shape a rule has to anticipate. As with SQL injection, a WAF also changes what your own scanner sees, and a block page can pass for a clean result.
A confirmed DAST finding tells you that a reachable request ran a command. It cannot tell you which line built it. A SAST finding tells you that request data reaches a process-starting call. It cannot tell you whether a deployed route sends it there, and it can be wrong in the ways the SAST false positive post describes, including a sink matched by name rather than by meaning.
The same bug appears outside web applications. In a CI workflow, an expression from an event payload is interpolated into a shell script before the shell runs, which is command injection with the pull request title as the request parameter. The GitHub Actions post covers it.
INJ42 from INJ$((6*7)), is not. Repeat with a different expression.$(...) inside double quotes says nothing about separators.--version on staging.-- where the program honours it, and an allowlist for the operand. For code injection, stop evaluating input. Do not rely on escaping.Back to the three hypothetical findings. The /diagnostics/ping?host= marker was a reflection. The page printed "Results for" followed by the host value, and a retest with 127.0.0.1;echo INJ$((6*7)) returned the payload literally, with no INJ42. The ping itself ran through an argument vector. The finding is reclassified as a reflected-input issue for the XSS review and closed as command injection, with the retest attached. The /webhooks/test?url= callback was DNS-only, on a parameter whose purpose is to fetch a URL. The application resolved the name it was given. That is not command injection, but it is a server-side request to an arbitrary host, and it moves to an SSRF review. The /repos/import?source= finding was the real one. --version changed the response because git parsed it as an option and rejected it as an unknown one, and on staging, --upload-pack with a harmless command ran that command. The list-form subprocess call had removed the shell and nothing else. The fix is -- before the source operand and an allowlist of repository URL schemes and hosts, and the rating goes from low to critical.
AppSecD's dynamic and API testing runs Nuclei, Dalfox, SQLMap, Commix, Katana, Arjun, FFuf and Interactsh for out-of-band detection, plus more than 150 native checks and API discovery across more than 50 web frameworks. Its static analysis includes dataflow taint analysis that traces source to sink across functions.
That is the whole claim. This post does not claim how AppSecD runs Commix, which techniques, payloads, shells or eval languages it tests, what timing thresholds or delays it uses, how it correlates Interactsh callbacks with requests or for how long, whether it uses a public or self-hosted out-of-band server, whether it tests for argument injection, whether it ever writes files to a target, what request rates it applies, whether its static rules treat queue messages as a taint source, or how accurate its command injection detection is. The measurements, the safety practices and the checklist above are general practice, and they apply to any scanner's output, ours included.