This is an automated email from the ASF dual-hosted git repository.
ash pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new b65690196d5 Protect supervisor memory from being read by sibling task
processes (#62523)
b65690196d5 is described below
commit b65690196d595f6edccecc625d37c6a180c03123
Author: Ash Berlin-Taylor <[email protected]>
AuthorDate: Fri Feb 27 10:43:50 2026 +0000
Protect supervisor memory from being read by sibling task processes (#62523)
Airflow task workers run all tasks as the same UID (unless you use
run_as_user, which most people don't). Each supervisor process holds a
distinct JWT token for API authentication. Without protection, any task
process can read a sibling supervisor's memory and steal its token via:
- /proc/<pid>/mem (direct memory read)
- /proc/<pid>/environ (read environment variables)
- /proc/<pid>/maps (find memory layout, then read)
- ptrace(PTRACE_ATTACH, ...) (debugger attach)
These all work because the kernel allows same-UID processes to access
each other by default. And being able to have one task impersonate another
task is not great for security controls we want to put in place.
Calling `prctl(PR_SET_DUMPABLE, 0)` tells the kernel to deny all four
vectors for non-root processes without `CAP_SYS_PTRACE`. Root-level
debugging tools (py-spy, strace, gdb under sudo) still work because
`CAP_SYS_PTRACE` bypasses the dumpable check.
The flag is set at the top of supervise(), before the Client is
constructed with the token. Since the task child is created via
os.fork() with no subsequent execve(), it inherits the non-dumpable
flag automatically — both supervisor and task processes are protected.
This is the same mechanism OpenSSH's ssh-agent uses to protect private
keys in memory:
https://github.com/openssh/openssh-portable/commit/6c4914afccb0c188a2c412d12dfb1b73e362e07e
and I think Chromium and KeePassXC etc use it similarly.
---
.../src/airflow/sdk/execution_time/supervisor.py | 25 +++++-
.../task_sdk/execution_time/test_supervisor.py | 88 ++++++++++++++++++++++
2 files changed, 112 insertions(+), 1 deletion(-)
diff --git a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
index b0878f766e2..9894d4fff31 100644
--- a/task-sdk/src/airflow/sdk/execution_time/supervisor.py
+++ b/task-sdk/src/airflow/sdk/execution_time/supervisor.py
@@ -324,6 +324,28 @@ def block_orm_access():
os.environ["AIRFLOW__CORE__SQL_ALCHEMY_CONN"] = conn
+# From <linux/prctl.h>
+_PR_SET_DUMPABLE = 4
+_PR_GET_DUMPABLE = 3
+
+
+def _make_process_nondumpable() -> None:
+ """Mark the current process as non-dumpable to prevent same-UID memory
access."""
+ if sys.platform != "linux":
+ return
+ try:
+ import ctypes
+
+ # CDLL(None) is dlopen(NULL) — a handle to the current process, which
always
+ # includes libc symbols since CPython is linked against it.
+ libc = ctypes.CDLL(None, use_errno=True)
+ rc = libc.prctl(_PR_SET_DUMPABLE, 0, 0, 0, 0)
+ if rc != 0:
+ log.warning("Failed to set PR_SET_DUMPABLE=0",
errno=ctypes.get_errno())
+ except Exception:
+ log.warning("Unable to set PR_SET_DUMPABLE=0", exc_info=True)
+
+
def _fork_main(
requests: socket,
child_stdout: socket,
@@ -2001,7 +2023,8 @@ def supervise(
:return: Exit code of the process.
:raises ValueError: If server URL is empty or invalid.
"""
- # One or the other
+ _make_process_nondumpable()
+
from airflow.sdk._shared.secrets_masker import reset_secrets_masker
if not client:
diff --git a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
index 9215f00f26c..46b7f660bb0 100644
--- a/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
+++ b/task-sdk/tests/task_sdk/execution_time/test_supervisor.py
@@ -137,6 +137,7 @@ from airflow.sdk.execution_time.supervisor import (
ActivitySubprocess,
InProcessSupervisorComms,
InProcessTestSupervisor,
+ _make_process_nondumpable,
_remote_logging_conn,
process_log_messages_from_subprocess,
set_supervisor_comms,
@@ -3150,3 +3151,90 @@ def test_reinit_supervisor_comms(monkeypatch,
client_with_ti_start, caplog):
"event": "is connected",
"timestamp": mock.ANY,
} in caplog, caplog.text
+
+
+_NOBODY_UID = 65534
+
+
+def _drop_root_if_needed():
+ """Drop to a non-root UID so kernel dumpable checks actually apply
(root/CAP_SYS_PTRACE bypasses them)."""
+ if os.getuid() == 0:
+ os.setuid(_NOBODY_UID)
+
+
[email protected](sys.platform != "linux", reason="PR_SET_DUMPABLE is
Linux-only")
+def test_nondumpable_blocks_sibling_proc_read():
+ """A sibling process (same non-root UID) cannot read /proc/<pid>/environ
or /proc/<pid>/mem of a nondumpable process."""
+ import multiprocessing
+
+ ready = multiprocessing.Event()
+ done = multiprocessing.Event()
+ result_queue = multiprocessing.Queue()
+
+ def target_fn():
+ _drop_root_if_needed()
+ _make_process_nondumpable()
+ ready.set()
+ done.wait(timeout=10)
+
+ def reader_fn(target_pid):
+ _drop_root_if_needed()
+ blocked = []
+ for proc_file in ("environ", "mem"):
+ try:
+ open(f"/proc/{target_pid}/{proc_file}").read()
+ except PermissionError:
+ blocked.append(proc_file)
+ result_queue.put(blocked)
+
+ target = multiprocessing.Process(target=target_fn)
+ target.start()
+ try:
+ assert ready.wait(timeout=5), "target process did not become ready"
+ reader = multiprocessing.Process(target=reader_fn, args=(target.pid,))
+ reader.start()
+ reader.join(timeout=5)
+ blocked = result_queue.get(timeout=5)
+ assert "environ" in blocked, "Sibling was able to read nondumpable
process's /proc/environ"
+ assert "mem" in blocked, "Sibling was able to read nondumpable
process's /proc/mem"
+ finally:
+ done.set()
+ target.join(timeout=5)
+ if target.is_alive():
+ target.kill()
+
+
[email protected](sys.platform != "linux", reason="PR_SET_DUMPABLE is
Linux-only")
+def test_nondumpable_blocks_child_memory_read():
+ """A forked child (same non-root UID) cannot read its nondumpable parent's
/proc/<pid>/mem."""
+ import multiprocessing
+
+ result_queue = multiprocessing.Queue()
+
+ def parent_fn():
+ _drop_root_if_needed()
+ _make_process_nondumpable()
+ parent_pid = os.getpid()
+ child_pid = os.fork()
+ if child_pid == 0:
+ try:
+ open(f"/proc/{parent_pid}/mem").read()
+ except PermissionError:
+ os._exit(0)
+ else:
+ os._exit(1)
+ _, status = os.waitpid(child_pid, 0)
+ result_queue.put(os.WEXITSTATUS(status) if os.WIFEXITED(status) else
-1)
+
+ proc = multiprocessing.Process(target=parent_fn)
+ proc.start()
+ proc.join(timeout=10)
+ exit_code = result_queue.get(timeout=5)
+ assert exit_code == 0, "Child was able to read parent's /proc/mem —
expected PermissionError"
+
+
[email protected](sys.platform == "linux", reason="Test is for non-Linux
platforms only")
+def test_nondumpable_noop_on_non_linux():
+ """On non-Linux, _make_process_nondumpable returns without error."""
+
+ _make_process_nondumpable()