· · 12 min leestijd Advanced

Runtime security met Falco: detecteer verdacht gedrag in je cluster

Implementeer Kubernetes runtime security met Falco. Detecteer verdachte syscalls, container escapes en afwijkend gedrag in real-time.

Een waakhondrobot met honderd oren grijpt een rat met bivakmuts die uit een containerkrat kruipt, zij houdt de riem

TL;DR Trivy en Kyverno keuren een image vóór hij draait. Falco kijkt wat de container daarna doet, syscall voor syscall, en slaat alarm als het gedrag niet bij een rule past. Deze post gaat van het ene ding dat Falco doet naar de config die ik draai, met het ruisprobleem ertussenin.

Ik scande m’n images met Trivy. Ik dwong policies af met Kyverno. Mijn workloads kregen cryptografische identiteit via SPIFFE. Drie lagen preventie, allemaal groen, en een tijdlang voelde dat als genoeg.

Toen vroeg ik me af wat er gebeurt nadat een pod draait. Mijn scanner keek naar de image die erin ging. Mijn admission controller keek naar de spec bij het deployen. Zodra het proces draait, kijkt geen van beide nog mee. Een zero-day die in een draaiende container landt, komt binnen als al die controls al klaar zijn.

Dat zat me meer dwars dan ik graag toegeef. Ik kan niet beweren dat ik mijn cluster begrijp als ik geen idee heb wat de processen erin uitvoeren. Preventie vertelt me wat waar zou moeten zijn. Ik wilde iets dat me vertelt wat wáár is, nu.

Dat iets is Falco. Het kijkt naar de system calls die elke container doet, bestandstoegang, netwerkverbindingen, processen die starten, en slaat alarm als het gedrag niet past bij de rules die ik het gaf. Zo heb ik het zelf geleerd. Eerst het ene ding dat het doet, dan een rule, dan mijn eigen rules, en dan wat ik draai en hoeveel daarvan tunen is.

Het gat dat preventie laat liggen

Clusterbeveiliging zie ik als drie momenten. Build time, waar Trivy de image scant op bekende kwetsbaarheden. Deploy time, waar Kyverno en de andere admission controllers de spec keuren. En runtime, waar iets moet kijken wat de workload doet. Falco is dat derde moment.

De meeste setups stoppen na de eerste twee, en de mijne lang ook. Goedkoper, past in de pipeline, en het dashboard wordt groen. Alleen speelt een aanvaller die al in een draaiende container zit jouw pipeline niet opnieuw af. Die werkt met wat er draait.

Dit is het soort gedrag dat die eerste twee momenten nooit zien:

  • Een container die ineens /bin/bash uitvoert in productie, waar niets ooit een shell zou moeten starten
  • Een proces dat /etc/shadow leest
  • Een uitgaande verbinding naar een crypto mining pool
  • Iets dat in /etc/ schrijft
  • Kubernetes service account tokens die gelezen worden vanuit een pod die er niets te zoeken heeft

Niks daarvan duikt op in een scan of een admission check, want niks ervan is configuratie. Het is gedrag, en gedrag is het enige waar Falco naar kijkt.

Eén ding, vóór er een rule is

Falco zit onder je containers, leest hun system calls en legt elke call langs een set condities. eBPF (of een kernelmodule) is hoe het die plek krijgt:

Kernel Space

User Space

Container A

Container B

Container C

eBPF / Kernel Module
(intercepts syscalls)

Falco
(rules engine)

Alerts
(stdout, webhook, Kafka...)

Elke syscall gaat door de rules engine. Een match wordt een alert. Dat is het hele model. De rest van Falco is detail daarbovenop, en zodra dit plaatje in je hoofd zit leest dat detail vanzelf.

Installeren

Ik begin met de Helm -chart en de eBPF-driver. eBPF betekent geen kernelmodule op elke node, en dat maakt uit op Talos en andere dichtgetimmerde hosts:

helm repo add falcosecurity https://falcosecurity.github.io/charts
helm repo update

helm install falco falcosecurity/falco \
  --namespace falco \
  --create-namespace \
  --set driver.kind=ebpf \
  --set falcosidekick.enabled=true

helm install draai ik alleen niet met de hand. Alles in mijn cluster gaat via Git, dus de echte versie is een ArgoCD-Application die naar dezelfde chart wijst:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: falco
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://falcosecurity.github.io/charts
    chart: falco
    targetRevision: 4.0.0
    helm:
      values: |
        driver:
          kind: ebpf
        falcosidekick:
          enabled: true
          config:
            slack:
              webhookurl: "https://hooks.slack.com/services/xxx"
        tty: true
  destination:
    server: https://kubernetes.default.svc
    namespace: falco
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Een rule lezen

Falco is zo goed als z’n rules en geen haar beter. Een rule is een klein blok YAML, en kun je er één lezen, dan kun je ze allemaal lezen. Het canonieke voorbeeld, een shell die start in een container:

- rule: Terminal shell in container
  desc: Detect shell being spawned in a container
  condition: >
    spawned_process and
    container and
    shell_procs and
    proc.tty != 0
  output: >
    Shell spawned in container
    (user=%user.name container=%container.name shell=%proc.name
     parent=%proc.pname cmdline=%proc.cmdline)
  priority: WARNING
  tags: [container, shell, mitre_execution]

Vier onderdelen doen het werk. De condition bepaalt wanneer hij afgaat, in Falco’s filtertaal. De output is wat in de alert komt, met de %-velden ingevuld op runtime. De priority is de severity, van DEBUG tot CRITICAL. De tags groeperen en filteren, hier gekoppeld aan MITRE-technique-categorieën.

Lees die condition hardop en het is bijna een gewone zin. Een proces is gestart, in een container, het proces is een shell, en er hangt een TTY aan. Elke andere rule hergebruikt die grammatica.

De rules die meegeleverd worden

Je begint niet met een leeg bestand. Falco levert een flinke set community rules mee die de voor de hand liggende aanvallen al afdekken:

# Bekijk geladen rules
kubectl exec -n falco -it falco-xxx -- falco --list

Container escapes, privilege escalation, gevoelige bestanden lezen, verdacht netwerkverkeer, misbruik van de Kubernetes-API. Een paar, zodat je ziet dat ze net zo lezen als die hierboven:

# Detecteer container escape via mount
- rule: Launch Sensitive Mount Container
  condition: >
    spawned_process and container and
    sensitive_mount

# Detecteer lezen van gevoelige bestanden
- rule: Read sensitive file untrusted
  condition: >
    open_read and sensitive_files and
    not trusted_containers

# Detecteer crypto mining
- rule: Detect outbound connections to crypto mining pools
  condition: >
    outbound and
    fd.sip.name in (cryptomining_pool_domains)

Rules die je app kennen

De defaults vangen generieke aanvallen. Wat jouw applicatie hoort te doen, weten ze niet. Die kennis heb jij, en die opschrijven is waar Falco interessant wordt:

# custom-rules.yaml
customRules:
  rules-custom.yaml: |-
    # Alert wanneer onze API server onverwachte processen spawnt
    - rule: Unexpected process in api-server
      desc: Detect processes other than the main app in api-server containers
      condition: >
        spawned_process and
        container.image.repository contains "api-server" and
        not proc.name in (api-server, node, npm)
      output: >
        Unexpected process in api-server
        (command=%proc.cmdline container=%container.name image=%container.image.repository)
      priority: WARNING

    # Alert op database verbinding van onverwachte pods
    - rule: Database connection from non-backend pod
      desc: Detect connections to PostgreSQL from pods that shouldn't connect
      condition: >
        outbound and
        fd.sport = 5432 and
        not k8s.pod.label.app in (api-server, worker, migration-job)
      output: >
        Unexpected database connection
        (pod=%k8s.pod.name namespace=%k8s.ns.name dest=%fd.sip)
      priority: ERROR

Custom rules gaan er via Helm-values in:

# values.yaml
customRules:
  rules-custom.yaml: |-
    - rule: My custom rule
      ...

De filtertaal

YAML kopiëren die ik niet kan lezen, daar heb ik een hekel aan, en ik hoop jij ook. Er is een handvol veldklassen, plus macros en lijsten om ze aan elkaar te lijmen. Ken die, en elke rule hierboven is geen black box meer.

Fields

# Process fields
proc.name         # Proces naam
proc.cmdline      # Volledige command line
proc.pname        # Parent proces naam
proc.exepath      # Executable pad

# Container fields
container.name    # Container naam
container.id      # Container ID
container.image.repository  # Image naam

# File fields
fd.name           # File descriptor naam (bestandspad)
fd.directory      # Directory van het bestand

# Network fields
fd.sip            # Server IP
fd.sport          # Server poort
fd.cip            # Client IP

# Kubernetes fields
k8s.pod.name      # Pod naam
k8s.ns.name       # Namespace
k8s.pod.label.app # Pod label

Macros

Macros zijn benoemde stukken conditie die je hergebruikt. Elke rule tot nu toe leunde erop zonder het te zeggen:

- macro: container
  condition: container.id != host

- macro: shell_procs
  condition: proc.name in (bash, sh, zsh, dash, ksh)

- macro: spawned_process
  condition: evt.type = execve and evt.dir = <

- macro: open_read
  condition: evt.type in (open, openat) and evt.is_open_read = true

Lijsten

Lijsten zijn benoemde sets waarden, waar je naar verwijst in plaats van ze te herhalen:

- list: sensitive_files
  items: [/etc/shadow, /etc/sudoers, /etc/pam.d, /root/.ssh]

- list: package_managers
  items: [apt, apt-get, yum, dnf, apk, pip, npm]

- list: shell_binaries
  items: [bash, sh, zsh, csh, tcsh, ksh, dash]

Drie rules die hun alert waard zijn

Met de grammatica in de hand, dit zijn de drie detecties die ik de ruis waard vind. Ze passen bij wat een aanvaller doet zodra die binnen is.

Reverse shells

- rule: Reverse shell detected
  desc: Detect reverse shells via common patterns
  condition: >
    spawned_process and
    container and
    ((proc.name = bash and proc.args contains ">&" and proc.args contains "/dev/tcp") or
     (proc.name = nc and proc.args contains "-e") or
     (proc.name = python and proc.args contains "socket" and proc.args contains "subprocess"))
  output: >
    Reverse shell detected (user=%user.name command=%proc.cmdline container=%container.name)
  priority: CRITICAL
  tags: [mitre_execution, reverse_shell]

Kubernetes-secrets lezen

- rule: K8s secret accessed from unexpected namespace
  desc: Detect when secrets are read from non-standard locations
  condition: >
    open_read and
    fd.name startswith "/var/run/secrets/kubernetes.io" and
    not k8s.ns.name in (kube-system, monitoring, vault)
  output: >
    K8s secret accessed (file=%fd.name pod=%k8s.pod.name namespace=%k8s.ns.name)
  priority: WARNING

Package-installaties op runtime

- rule: Package manager in container
  desc: Detect package installations in running containers
  condition: >
    spawned_process and
    container and
    proc.name in (apt, apt-get, yum, dnf, apk, pip, npm) and
    not container.image.repository in (allowed_builder_images)
  output: >
    Package manager run in container
    (command=%proc.cmdline container=%container.name image=%container.image.repository)
  priority: ERROR
  tags: [mitre_persistence, package_install]

Een package manager die opstart in een draaiende container betekent dat iemand een voet tussen de deur zet, of op z’n best dat een developer in productie aan het debuggen is. Allebei wil ik weten, dus het is een ERROR en geen warning.

De alerts ergens krijgen waar je ze ziet

Iets detecteren heeft geen zin als de alert sterft in de stdout van een pod, en standaard schrijft Falco precies daarheen. Falcosidekick waaiert de alerts uit naar waar je wél kijkt:

falcosidekick:
  enabled: true
  config:
    slack:
      webhookurl: "https://hooks.slack.com/services/xxx"
      minimumpriority: warning

    prometheus:
      enabled: true

    elasticsearch:
      hostport: "elasticsearch.logging:9200"
      index: "falco"

    alertmanager:
      hostport: "http://alertmanager.monitoring:9093"

    webhook:
      address: "http://security-responder.security:8080/falco"

Die config stuurt warnings en hoger naar Slack, elke alert als metric naar Prometheus, alles naar Elasticsearch voor de historie, criticals naar Alertmanager om te pagen, en de hele stroom naar een webhook voor geautomatiseerde response. Eén detectie, en elke bestemming krijgt de severity die haar aangaat.

Falco in Prometheus vouwen

Prometheus en Grafana draai ik al voor al het andere, dus ik wil security-events op de plek waar ik de rest van het cluster bekijk. Falcosidekick stelt metrics beschikbaar, en een ServiceMonitor scrapet ze:

# ServiceMonitor
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: falco-sidekick
  namespace: monitoring
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: falcosidekick
  endpoints:
    - port: http

Vanaf daar maakt een PrometheusRule van een Falco-detectie hetzelfde soort alert als een volle disk of een hete CPU:

# PrometheusRule
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: falco-alerts
spec:
  groups:
    - name: falco
      rules:
        - alert: FalcoCriticalAlert
          expr: increase(falco_events{priority="Critical"}[5m]) > 0
          for: 0m
          labels:
            severity: critical
          annotations:
            summary: "Falco critical security event detected"
            description: "{{ $labels.rule }} triggered in {{ $labels.k8s_ns_name }}/{{ $labels.k8s_pod_name }}"

De eerste week is ruis

Dit is de trade-off die niemand op de landingspagina zet. Out of the box pagen de default rules je voor dingen die in jouw cluster doodnormaal zijn. De eerste week dat ik Falco draaide, werd ik wakker gemaakt door m’n eigen config-reload-sidecar. Een tool die je hebt leren negeren heb je uitgezet zonder het te merken, dus tunen is geen stapje na de installatie. Het is het meeste werk.

Twee zetten. Rules uitzetten die niet passen, en de macros versmallen waar de rest van afhangt.

Rules uitzetten en overschrijven

# values.yaml
falco:
  rules_file:
    - /etc/falco/falco_rules.yaml
    - /etc/falco/falco_rules.local.yaml  # Overrides
    - /etc/falco/rules.d  # Custom rules

customRules:
  falco_rules.local.yaml: |-
    # Schakel luidruchtige rule uit
    - rule: Terminal shell in container
      enabled: false

    # Wijzig bestaande rule om bepaalde containers uit te sluiten
    - rule: Read sensitive file untrusted
      condition: >
        open_read and sensitive_files and
        not trusted_containers and
        not container.image.repository in (my-trusted-app)

Macros tunen

customRules:
  falco_rules.local.yaml: |-
    # Voeg toe aan trusted containers
    - macro: trusted_containers
      condition: >
        (container.image.repository in (
          my-company/trusted-app,
          my-company/another-app
        ))

    # Sluit namespaces uit van monitoring
    - macro: user_namespace
      condition: >
        k8s.ns.name not in (kube-system, falco, monitoring)

trusted_containers aanpassen wint van de rule uitzetten, want de rule beschermt dan nog elke andere container. Je leert Falco hoe normaal er hier uitziet, in plaats van het blind te maken.

Wat ik draai

Alles op elkaar. De eBPF-driver, JSON-output, getrapte alerting, en twee rules die zeggen wat m’n eigen workloads mogen. Leg het naast de helm install van bovenaan en je ziet hoeveel ervan tuning is:

driver:
  kind: ebpf
  ebpf:
    hostNetwork: true

falco:
  grpc:
    enabled: true
  grpc_output:
    enabled: true
  json_output: true
  log_level: info

  rules_file:
    - /etc/falco/falco_rules.yaml
    - /etc/falco/falco_rules.local.yaml
    - /etc/falco/rules.d

falcosidekick:
  enabled: true
  replicaCount: 2
  config:
    slack:
      webhookurl: "${SLACK_WEBHOOK}"
      minimumpriority: error

    prometheus:
      enabled: true

    alertmanager:
      hostport: "http://alertmanager.monitoring:9093"
      minimumpriority: critical

customRules:
  rules-custom.yaml: |-
    # Onze applicatie-specifieke rules
    - rule: Unexpected outbound connection from backend
      desc: Backend pods should only connect to known services
      condition: >
        outbound and
        k8s.pod.label.app = "backend" and
        not fd.sip.name in (postgres.db, redis.cache, api.internal)
      output: >
        Backend made unexpected outbound connection
        (pod=%k8s.pod.name dest=%fd.sip:%fd.sport)
      priority: WARNING

  falco_rules.local.yaml: |-
    # Stem default rules af voor onze omgeving
    - macro: user_known_write_etc_conditions
      condition: >
        (container.image.repository = "my-company/config-manager")

Waarom elke keuze. eBPF boven de kernelmodule omdat het beter presteert en de host schoner laat. JSON-output omdat downstream tools het parsen zonder regex. Getrapte alerting zodat een error Slack port en een critical iemand paget. En custom rules omdat alleen ik weet waar mijn backend mee hoort te praten.

Geautomatiseerde response

Falco detecteert alleen. Wat daarna gebeurt is aan jou. Voor de meeste rules wil ik dat een mens kijkt. Voor een CRITICAL vind ik het best dat het cluster zichzelf verdedigt, en de webhook-ontvanger uit de routing-config is waar dat zit:

# security-responder deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: security-responder
spec:
  template:
    spec:
      containers:
        - name: responder
          image: my-company/security-responder
          env:
            - name: SLACK_WEBHOOK
              valueFrom:
                secretKeyRef:
                  name: security-responder
                  key: slack-webhook

De ontvanger kan klein zijn. Kill de pod, laat de Deployment hem opnieuw maken, en post naar Slack zodat een mens het weet:

@app.route('/falco', methods=['POST'])
def handle_falco_alert():
    alert = request.json

    if alert['priority'] == 'Critical':
        # Kill de pod direct
        pod = alert['output_fields']['k8s.pod.name']
        namespace = alert['output_fields']['k8s.ns.name']

        # Delete pod (Deployment zal hem herstellen)
        v1.delete_namespaced_pod(pod, namespace)

        # Notificeer
        send_slack(f"Pod {namespace}/{pod} gekilld vanwege: {alert['rule']}")

    return 'ok'

Een pod killen op één alert is scherp gereedschap en ik ga er voorzichtig mee om. Hang er een ruizige rule aan en je hebt iets gebouwd dat je eigen cluster platlegt. Dus automatisering komt alleen op rules die ik lang genoeg in productie heb zien draaien om ze te vertrouwen. De rest paget een mens.

Zou moeten en is

Stap weg van de YAML en de reden dat ik Falco draai is de reden dat ik de rest van mijn stack zelf host. Ik wil weten wat m’n infrastructuur doet, niet aannemen. Trivy en Kyverno vertellen me wat mijn cluster zou moeten zijn. Falco vertelt me wat het is. Zonder Falco was m’n eerlijke antwoord op “wat draait er nu in je cluster” iets als “wat de image drie weken geleden zei, waarschijnlijk”.

Dat gat tussen zou moeten en is, tussen de spec en het draaiende proces, is waar een aanvaller werkt. De drie lagen waar ik mee opende zijn nog steeds groen. Falco is de laag die sindsdien iets te melden had, en de eerste week ging dat vooral over m’n eigen sidecar.