Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

What a Shell

A playground for seeing what a shell one-liner actually spawns - and, often enough, for asking what a hell it just did.

Paste a command into a web page; whatashell runs it inside a throwaway cgroup, captures the whole process tree with eBPF tracepoints on fork/exec/exit, and shows you every process with its full argv - including the ones that lived for a hundred microseconds and never appeared in /proc.

It is built for detection engineering: write a payload, see what the kernel saw, watch a naive argv rule fire, then write the payload that slips past it.

$ id; echo aWQK | base64 -d | sh

exit 0   /bin/bash -c 'id; echo aWQK | base64 -d | sh'
|-- id
|-- echo aWQK
|-- base64 -d
`-- sh
    `-- id

Why not just scrape /proc

Because most of the tree is already gone. Every run also polls cgroup.procs + /proc/<pid>/{stat,cmdline} at a configurable interval, and the run page puts the two counts side by side:

    7            7             1              6
processes    programs     seen by /proc   missed by
  (eBPF)      exec'd         poll         polling

That gap is the argument for a kernel hook, and it is why the sampler is in the tool rather than left as an exercise.

How the capture is scoped

Each run gets its own cgroup v2 directory. The command is started with clone3(CLONE_INTO_CGROUP), so it is inside the cgroup before its first instruction - there is no window in which a fork could escape unobserved.

Three BTF tracepoint programs report only tasks in a tracked cgroup:

program tracepoint what it contributes
sched_process_fork tp_btf/sched_process_fork the edges of the tree; reads the child's cgroup, which is what catches the root of the run
sched_process_exec tp_btf/sched_process_exec the program and full argv, read from mm->arg_start..arg_end of the new address space
sched_process_exit tp_btf/sched_process_exit exit status, so && and || chains make sense

A second map keyed by pid is filled in on fork, so a descendant that writes its own pid into another cgroup, or gets reparented to init, is still attributed to the run that spawned it. Nothing outside a tracked run is ever collected.

tp_btf rather than the classic tracepoint/sched/* programs, because those hand you a pid where these hand you a task_struct - which is what makes the child's cgroup and the full argv reachable.

Requirements

  • Linux 5.14+ (cgroup.kill; the rest works from 5.7) with cgroup v2 mounted at /sys/fs/cgroup and CONFIG_DEBUG_INFO_BTF=y
  • root, or CAP_BPF + CAP_PERFMON + CAP_SYS_ADMIN
  • Go 1.25, and clang + libbpf headers only if you regenerate the BPF objects

Build and run

go build -o whatashell ./cmd/whatashell
$ sudo ./whatashell
2026/08/26 18:04:11 whatashell: serving on http://127.0.0.1:8088
2026/08/26 18:04:11 whatashell: submitted commands run as danielpacak under /bin/bash

Then open http://127.0.0.1:8088.

The daemon needs root to load BPF programs, but the commands you submit do not: they run as the user who called sudo, falling back to nobody. Pass --run-as-root if you specifically want to watch what a root payload does.

Regenerating the BPF objects (only needed after editing the C):

go generate -tags tools ./...

Flags

flag default
--listen 127.0.0.1:8088 UI address
--shell /bin/bash interpreter for submitted commands
--timeout 10s after this the run's cgroup is killed
--linger 500ms keep watching after the shell exits, to catch cmd &
--poll 10ms procfs sampler interval
--run-as $SUDO_USER, else nobody who submitted commands run as
--run-as-root false do not drop privileges
--cgroup-root /sys/fs/cgroup cgroup v2 mount point
--cgroup-parent whatashell directory for per-run cgroups
--work-root /tmp/whatashell per-run scratch directories
--history 50 runs to keep
--max-concurrent 4 runs in flight
--output-limit 65536 bytes of stdout and of stderr kept per run

Endpoints

route
GET / the form, the examples, and the run history
POST /runs submit a command
GET /runs/{id} the captured tree, rule matches, output, raw events
GET /runs/{id}/tree.txt the tree as text, for pasting into a ticket
GET /runs/{id}/run.json the whole capture as JSON, for feeding a rule engine

A word on what this is

whatashell runs whatever you type, as a service. That is the entire point, and it is also why it binds to localhost, drops privileges, bounds every run with a timeout, and kills the cgroup afterwards. None of that makes it safe to expose: run it in a VM you can throw away, not on anything you care about.

The rules in internal/detect are deliberately naive substring and regexp matches over argv. They are there to be evaded, not deployed.

Limits worth knowing

  • argv is copied up to 2 KiB per exec; longer command lines are truncated and flagged as such in the UI.
  • Threads are not nodes in the tree - only thread group leaders are, since those are what fork and exec produce.
  • A process that survives the linger window is killed with the cgroup, so its exit event is the kill, not what it would have done.
  • Rendering waits for the run to finish; the page refreshes every second while it is in flight.

About

A playground for seeing what a shell one-liner actually spawns - and, often enough, for asking what a hell it just did.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages