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
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.
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.
- Linux 5.14+ (
cgroup.kill; the rest works from 5.7) with cgroup v2 mounted at/sys/fs/cgroupandCONFIG_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
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/bashThen 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 ./...| 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 |
| 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 |
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.
argvis 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
forkandexecproduce. - 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.