Skip to content

bug(plugins/container): unbounded blocking in engine bootstrap hangs Falco startup #1487

Description

@leogr

Describe the bug

StartWorker bootstraps all enabled container engines synchronously, on the caller's thread, before returning. The context it uses is derived from context.Background() with cancel only, so it has no deadline. As a consequence, a runtime socket that accepts a connection but never answers blocks the call forever.

Since StartWorker is invoked from start_async_events, which libs calls from sinsp::open_common() on Falco's main thread, this hangs Falco's startup permanently, before the event loop is even reached.

Reported downstream at falcosecurity/falco#3953

The chain:

  • StartWorker runs generator(ctx) and then engine.List(ctx) for each engine, serially and inline, with no deadline 👇
    for _, generator := range generators {
    engine, err := generator(ctx)
    if err != nil {
    continue
    }
    containerEngines = append(containerEngines, engine)
    if _, ok := enabledEngines[engine.Name()]; !ok {
    enabledEngines[engine.Name()] = make([]string, 0)
    }
    enabledEngines[engine.Name()] = append(enabledEngines[engine.Name()], engine.Sock())
    // List all pre-existing containers and run `goCb` on all of them
    containers, err := engine.List(ctx)
    if err == nil {
    for _, ctr := range containers {
    goCb(ctr.String(), true, true)
    }
    }
    }
  • newPodmanEngine calls bindings.NewConnection, which pings the service with a GET /_ping. For unix:// URIs, the podman bindings build an http.Client with no Timeout and a net.Dialer with no timeout 👇
    func newPodmanEngine(ctx context.Context, _ *slog.Logger, socket string) (Engine, error) {
    conn, err := bindings.NewConnection(ctx, enforceUnixProtocolIfEmpty(socket))
    if err != nil {
    return nil, err
    }
    return &podmanEngine{pCtx: conn, socket: socket}, nil
    }
  • podmanEngine.List discards its ctx parameter and uses the connection context instead 👇
    func (pc *podmanEngine) List(_ context.Context) ([]event.Event, error) {
  • Sockets are not reachability-probed. Generators() creates an engine for any path whose os.Stat does not return NotExist 👇
    func Generators() ([]EngineGenerator, error) {
    generators := make([]EngineGenerator, 0)
    c := config.Get()
    for engineName, engineGen := range engineGenerators {
    eCfg, ok := c.SocketsEngines[string(engineName)]
    if !ok || !eCfg.Enabled {
    continue
    }
    // For each specified socket, return a closure to generate its engine
    for _, socket := range eCfg.Sockets {
    // Properly account for HOST_ROOT env variable
    socket = filepath.Join(config.GetHostRoot(), socket)
    // Even if `stat` returns an err that is not NotExist,
    // try to generate an engine for the socket.
    if _, statErr := os.Stat(socket); !os.IsNotExist(statErr) {
    generators = append(generators, func(ctx context.Context) (Engine, error) {
    return engineGen(ctx, slog.With("engine", engineName), socket)
    })
    }
    }
    }
    return generators, nil
    }

Affected engines:

Engine Connect timeout List bounded
podman none no (ignores ctx)
docker none no
containerd none no
cri 5s yes

So cri is the only one already protected 👇

func newCriEngine(ctx context.Context, logger *slog.Logger, socket string) (Engine, error) {

Aggravating factor: rootless podman auto-discovery

When engines.podman.sockets is empty, the plugin adds /run/podman/podman.sock and scans /run/user/*/podman/podman.sock, enabling everything it finds 👇

if(cfg.engines.podman.sockets.empty())
{
cfg.engines.podman.sockets.emplace_back("/run/podman/podman.sock");
try
{
for(const auto& entry : std::filesystem::directory_iterator(
cfg.host_root + "/run/user"))
{
if(entry.is_directory())
{
if(std::filesystem::exists(entry.path().string() +
"/podman/podman.sock"))
{
// Remove host root since it will be later added by
// go-worker itself
auto root = entry.path().string().substr(
cfg.host_root.length());
cfg.engines.podman.sockets.emplace_back(
root + "/podman/podman.sock");
}
}
}
}
catch(...)
{
// No error; perhaps /run/user does not exist.
}
}

That scan is active on host installs, since Falco ships engines unset ("We use default config values for engines key" in falco.yaml).

Those sockets are socket-activated by systemd, per user. Falco runs as root, so connect() succeeds because systemd owns the listener, but the response only arrives once that user's podman.service starts. If the user manager is not running, no response ever arrives.

N.B. The Helm chart pins podman.sockets: ["/run/podman/podman.sock"], which suppresses the scan. So Kubernetes deployments are largely shielded, and host RPM/DEB installs are the exposed configuration.

How to reproduce it

  1. Install Falco from the RPM/DEB packages, so that engines is left at its defaults
  2. Make a unix socket available at /run/user/<uid>/podman/podman.sock that accepts connections but never writes a response. A socket-activated rootless podman.service that cannot start reproduces this naturally (this is what the reporter hit). For a synthetic repro, something like socat UNIX-LISTEN:<path>,fork SYSTEM:'sleep infinity' should do, though I have not verified that exact invocation yet
  3. Start Falco

Falco logs up to Trying to open the right engine! and then hangs indefinitely. SIGINT and SIGTERM have no effect, since they only set a flag that the event loop polls, so only SIGKILL works.

Expected behaviour

An unresponsive or misbehaving runtime socket must never be able to block Falco's startup. The engine bootstrap should be bounded, and an engine that does not answer in time should be reported as unavailable instead of hanging.

Environment

  • Container plugin version: 0.7.1, and also reproducible on main (no timeout changes have landed on go-worker since 0.7.1)
  • Falco version: 0.44.1
  • libs version: 0.25.4
  • OS: Rocky Linux 10.2, kernel 6.12.0-211.42.1.el10_2.x86_64
  • Installation method: RPM, also reported with the Docker/Podman images
  • Drivers: reproduced with both kmod and modern_ebpf, since the plugin bootstrap runs before the driver matters

Proposed fix

In order of risk, from lowest:

  1. Bound the bootstrap. Wrap each generator(ctx) and engine.List(ctx) in a context.WithTimeout inside StartWorker. This just normalizes the behaviour to what cri already does, and the timeout should be configurable.
  2. Honour the passed ctx. podmanEngine.List currently discards it, so (1) alone would have no effect for podman. The other engines should be audited for the same problem.
  3. Set explicit client timeouts where the SDK allows it, i.e. client.WithTimeout for docker and containerd.WithTimeout for containerd. The podman bindings own their http.Client, so the ctx deadline from (1) is the only lever there.
  4. Follow-up, larger change: do not block startup at all. Move the pre-existing container enumeration into the worker goroutine and let StartWorker return immediately. N.B. this needs care, since capture_open enriches the initial thread table right afterwards, so it would race against the merge of s_preexisting_containers. I'd do 1-3 first and treat this one separately.

Additional context

There is also a design question worth discussing. Auto-enabling every /run/user/*/podman/podman.sock found on disk gives a root-running Falco a hard dependency on arbitrary users' socket-activated services. At the very least, those sockets should be reachability-probed with a short timeout before being enabled. Arguably, the scan should be opt-in. wdyt? 🤔

cc @deepskyblue86 @FedeDP

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions