← Back to Blog

The Linux Process That Lies About Its Own Name

12 September 2026 Incident Response & Investigation 8 min read
One process, three fields, two answers PID 446 on a host you are triaging /proc/446/cmdline [kworker/u ps -p 446 -o args= [kworker/u written by the process readlink /proc/446/exe /tmp/nstat written by the kernel A real kernel thread has no exe link at all. So a process that claims to be one and has an exe link is lying about itself. No malware analysis required to see it. The contradiction is the finding. You do not need to know what the binary does to know it is misrepresenting itself.

You run ps on a host somebody has asked you to look at, and you read down the list for something that does not belong. That habit is older than most of the tooling on the box, and on Linux it has a specific weakness: the process chooses what ps prints.

Not the kernel. The process.

Three lines of C

A program's arguments live in its own memory. argv[0] is a pointer into that memory, the process can write to it, and /proc/PID/cmdline reads back whatever is there at the moment you look.

size_t n = strlen(argv[0]);
memset(argv[0], 0, n);
strncpy(argv[0], "[kworker/u8:2]", n);

That is the whole technique. Compile it, run the binary from /tmp, and a second later:

$ ps -p 446 -o comm=,args=
nstat           [kworker/u

$ tr '\0' ' ' < /proc/446/cmdline
[kworker/u

The square brackets are the convention ps uses for kernel threads, and kworker is one of the most common names on any Linux host. An analyst skimming a process list has been trained by thousands of hours of normal output to skip that line.

It is worth being precise about what ps is doing here, because the tool is not at fault. ps reads /proc/PID/cmdline and prints it. That file is not a record the kernel keeps about the process; it is a window onto a region of the process's own address space, bounded by where the arguments were placed at exec time. The kernel will hand you whatever is currently in that region. A process editing its own arguments is not exploiting anything, and there is no patch for this, because nothing is broken. The interface does exactly what it documents.

That distinction matters for triage, because it tells you where else to be careful. Anything derived from cmdline inherits the same weakness: pgrep -f, a ps | grep in a runbook, an alert rule keyed on a command-line string, a collection script that records what was running by writing ps aux to a file. All of them are recording claims. If the claim is what the process chose to present, then so is your evidence.

What the process could not rewrite

Run the same check against the fields the process does not own:

$ readlink /proc/446/exe
/tmp/nstat

$ cat /proc/446/comm
nstat

/proc/PID/exe is a symlink the kernel maintains to the inode that was executed. A process cannot write to it. It survives the binary being deleted from disk, in which case it reads /tmp/nstat (deleted), which is louder still.

There is a second-order benefit to reading exe rather than parsing a name. It resolves through to the inode, so it tells you the real path even when the process was started through a symlink, and it keeps pointing at the right file if the binary is later moved. A name can be duplicated anywhere on the filesystem; the link cannot.

/proc/PID/comm is also interesting, and it is the field most people expect to follow the lie. It does not. It is set from the executable name at exec time and only changes if the process explicitly calls prctl(PR_SET_NAME). Rewriting argv[0] leaves it alone. Our process shows nstat in comm while cmdline says [kworker/u, which is a contradiction on one host between two files in the same directory.

Two limits worth knowing, because they shape what you will actually see. The fake name is bounded by the length of the original argv[0]: our binary at /tmp/nstat gave ten writable bytes, so [kworker/u8:2] was truncated to [kworker/u, missing its closing bracket. A longer original path gives a longer lie. Separately, comm is capped at 15 characters by TASK_COMM_LEN, so a binary named averyveryverylongname reports as averyveryverylo. Truncation is normal there and is not itself suspicious.

The check that does not need a malware verdict

You do not need to know what /tmp/nstat does. You need to know that it is claiming to be something it is not, and that is a property of two fields disagreeing.

A real kernel thread has no exe link. Not an empty one, not a broken one: readlink returns nothing, because there is no userspace binary behind it. So the rule is short.

for p in $(ls /proc | grep '^[0-9]*$'); do
  c=$(tr -d '\0' < /proc/$p/cmdline 2>/dev/null)
  case "$c" in
    \[*) e=$(readlink /proc/$p/exe 2>/dev/null)
         [ -n "$e" ] && echo "pid $p claims '$c' but exe is $e" ;;
  esac
done

On the host above that prints one line:

pid 446 claims '[kworker/u' but exe is /tmp/nstat
It costs almost nothing. Reading the exe link for every process on a 50-process container took 44 ms end to end. On a busy server with several hundred processes you are still comfortably inside a second. This is not a scan you schedule for a maintenance window; it is a check you can run at the top of every triage.

Detecting it from your EDR instead

If you have Defender for Endpoint on Linux, the same contradiction is visible in DeviceProcessEvents, because the telemetry carries both the reported command line and the resolved path of the image.

DeviceProcessEvents
| where Timestamp > ago(7d)
| where InitiatingProcessFolderPath != "" or FolderPath != ""
| where ProcessCommandLine startswith "["
| where isnotempty(FolderPath)
| project Timestamp, DeviceName, ProcessCommandLine, FolderPath,
          AccountName, InitiatingProcessFileName
| sort by Timestamp desc

The same logic in Splunk, over whatever process telemetry you have normalized:

index=linux sourcetype=osquery:processes
| where like(cmdline, "[%")
| where isnotnull(path) AND path!=""
| table _time host pid cmdline path uid
| sort -_time

And as a Sigma rule, so it travels to whichever backend you are actually running:

title: Process Claiming Kernel Thread Name With Userspace Image
id: 6d5f2a71-3e94-4c2b-9a0d-71f4c8e2b533
status: experimental
description: A process reports a bracketed kernel-thread style command line while
  having a resolved image path on disk. Kernel threads have no backing executable.
logsource:
  product: linux
  category: process_creation
detection:
  cmdline_brackets:
    CommandLine|startswith: '['
  has_image:
    Image|startswith: '/'
  condition: cmdline_brackets and has_image
falsepositives:
  - Processes that legitimately set a bracketed argv[0], which is rare but not unknown
  - Telemetry that synthesizes CommandLine from comm rather than reading cmdline
level: high
tags:
  - attack.defense-evasion
  - attack.t1036.004

That is T1036.004, Masquerade Task or Service, and the wider technique is T1036. If you want to generate the telemetry safely before you trust the rule, the three lines of C at the top of this post are the whole test harness, and Atomic Red Team carries related masquerading tests you can run on a lab host.

The false positive that tells you something

The rule will fire on things that are not malicious, and the ones it catches are worth knowing about.

Some legitimate software rewrites argv[0] deliberately. Process supervisors relabel their workers. A few database and mail daemons set a descriptive status into argv[0] so that ps shows what each worker is currently doing, which is genuinely useful and long predates anybody using the trick to hide.

None of those disguise themselves as kernel threads. They are trying to be more informative in the process list, not less, which is the opposite intent even though it is the same mechanism. The bracket convention is the tell, and that is why the rule keys on it rather than on the broader fact of argv[0] having changed. If your estate has a process legitimately claiming a bracketed name, you want to know that too: it is a thing your detection will trip over forever, and it is better to have it written down than rediscovered every time somebody runs the check.

What this changes about reading a process list

The general lesson is larger than one technique, and it is the reason the check is cheap.

Fields a process controls are claims. Fields the kernel maintains are evidence. cmdline is a claim. exe is evidence. Most of the triage habits people bring to Linux are built on the first category, because that is what ps prints and ps is what everybody reaches for.

The same split shows up elsewhere on the same host. A process's start time comes from its stat entry and the boot time in /proc/stat, neither of which it can rewrite, which is why a start time will sometimes contradict a log entry that somebody edited. Open file descriptors under /proc/PID/fd are kernel-maintained too, and they will show you a deleted binary or an unexpected socket that no command-line inspection would.

Once you are looking for the distinction, a lot of triage gets faster, because you stop weighing contradictory evidence and start noticing which side of the line each piece came from.

It also changes what you collect. A triage script that captures ps aux and stops has recorded one claim per process. The same script capturing the exe link, the start time and the open descriptors alongside it has recorded something an adversary on that host could not have edited on the way past. The second costs a few milliseconds more per process and is worth strictly more later, particularly if the question you end up answering is not the one you thought you were collecting for.

None of this makes ps useless. It makes it the first line of a two-line check, and the second line is the one that decides.

What to do this week

  1. Run the loop above on one production host. It takes under a second and needs no installation. If it returns nothing, you have a measured baseline rather than an assumption, and that is worth recording.
  2. Run it across the fleet and keep the output. Any host that returns a line needs looking at. Any host that has always returned nothing and suddenly returns one is a much stronger signal than a first-time result.
  3. Deploy the Sigma rule to whichever backend you use, and accept that it will find your legitimate argv[0] rewriters first. Write those down as named exceptions rather than tuning the rule broader.
  4. Add readlink /proc/PID/exe to your triage notes wherever they currently say ps. Not as a replacement, as the second line. The first tells you what the host claims; the second tells you what it is running.
  5. Check what your EDR actually records. Some Linux agents populate their command-line field from comm rather than cmdline, which means the rule above will never fire for them and you will believe you have coverage you do not have. Generating the telemetry with the test binary is the only way to know.
Ridgeline Cyber Defence Written by security professionals. Published weekly on Tuesdays.

Related Articles

4 August 2026

The Hunt That Found Nothing

An advisory lands, you sweep the estate, nothing comes back. A fleet-wide clean result has a numerator and no denominato

16 June 2026

Catch C2 Beaconing by Its Cadence in Sentinel and Splunk

IP and domain indicators expire within days. The interval a beacon sleeps on does not. How to score connection cadence i

3 May 2026

We Open-Sourced Our Incident Response Toolkit: 28 Use Cases, One Binary, Zero Install

VanGuard: open-source DFIR toolkit that replaces the 45-minute tooling scramble at incident start. 28 use cases, cross-p