fix: stage root writes in a mktemp file, never at a fixed /tmp path
CI / test (3.10) (push) Successful in 46s
CI / test (3.11) (push) Successful in 39s
CI / test (3.12) (push) Successful in 53s
CI / test (3.10) (pull_request) Successful in 44s
CI / test (3.11) (pull_request) Successful in 47s
CI / test (3.12) (pull_request) Successful in 47s
CI / test (3.10) (push) Successful in 46s
CI / test (3.11) (push) Successful in 39s
CI / test (3.12) (push) Successful in 53s
CI / test (3.10) (pull_request) Successful in 44s
CI / test (3.11) (pull_request) Successful in 47s
CI / test (3.12) (pull_request) Successful in 47s
Now that a whole _sudo line runs as root, `mv /tmp/netork_snmpd.conf /etc/snmp/snmpd.conf && chown root:root … && chmod 644 …` would have let a local user plant a symlink at the fixed /tmp path and have root's chown and chmod follow it (e.g. to /etc/shadow). The same held for 00proxy. _install_file_as_root stages the content unprivileged in a fresh mktemp file (umask 077), checks the path it got back, and root installs it as a new regular file next to the target and renames it over: nothing root runs follows a symlink or changes the owner or mode of an existing path. Nothing runs as root when staging failed. get_vpn_tunnels always reads `wg show all dump` from the exec channel; the predictable /tmp/.netork_wg_<hash> file holding the private keys is gone.
This commit is contained in:
1 parent
cf8ee38a90
commit
108f6fb3ca
3 files changed
+307
-28
No files matched your search
@@ -0,0 +1,246 @@
|
||||
"""Files root writes are staged in a fresh file, never at a fixed path in /tmp (NetOrk/netork#1039).
|
||||
|
||||
The snmpd and apt proxy fixes wrote their content to ``/tmp/netork_snmpd.conf``
|
||||
or ``/tmp/netork_00proxy`` as the login user, then ran ``mv … && chown root:root
|
||||
… && chmod 644 …``. Once the whole of a ``_sudo`` command ran as root, that was
|
||||
a local privilege escalation: another user creates the fixed path first as a
|
||||
symlink to ``/etc/shadow``, the login user's write fails (protected_symlinks),
|
||||
root moves the symlink into place, and root's ``chown``/``chmod`` follow it --
|
||||
``/etc/shadow`` mode 644.
|
||||
|
||||
Now the content goes to a ``mktemp`` file under ``umask 077``, unprivileged, and
|
||||
root ``install``s that file as a new regular file next to the target, then
|
||||
renames it over the target. Nothing root runs changes the owner or mode of a
|
||||
path that could be a symlink.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from napalm_linux import LinuxDriver
|
||||
|
||||
_PW = "pw-1039"
|
||||
_STAGED = "/tmp/netork-stage.Ab12Cd"
|
||||
#: What another local user planted where the fixes used to stage their content.
|
||||
_PLANTED = {
|
||||
"/tmp/netork_snmpd.conf": "/etc/shadow",
|
||||
"/tmp/netork_00proxy": "/etc/shadow",
|
||||
}
|
||||
|
||||
|
||||
class Host:
|
||||
"""The host's exec channels: every line, its stdin, and whether it ran as root."""
|
||||
|
||||
def __init__(self, *, stage_output=_STAGED, stage_status=0):
|
||||
self.runs: list[SimpleNamespace] = []
|
||||
self._stage = (stage_output, stage_status)
|
||||
|
||||
def open_session(self):
|
||||
return _Channel(self)
|
||||
|
||||
def answer(self, command: str) -> tuple[str, int]:
|
||||
if "mktemp" in command:
|
||||
return self._stage
|
||||
return "", 0
|
||||
|
||||
@property
|
||||
def root_scripts(self) -> list[str]:
|
||||
return [run.script for run in self.runs if run.root]
|
||||
|
||||
@property
|
||||
def unprivileged(self) -> list[SimpleNamespace]:
|
||||
return [run for run in self.runs if not run.root]
|
||||
|
||||
|
||||
class _Channel:
|
||||
def __init__(self, host: Host):
|
||||
self._host = host
|
||||
self._run: SimpleNamespace | None = None
|
||||
self._out = b""
|
||||
|
||||
def exec_command(self, command):
|
||||
assert _PW not in command
|
||||
root = command.startswith(("sudo ", "sh -c "))
|
||||
script = shlex.split(command)[shlex.split(command).index("-c") + 1] if root else command
|
||||
self._run = SimpleNamespace(line=command, script=script, root=root, stdin=b"")
|
||||
self._host.runs.append(self._run)
|
||||
output, self._status = self._host.answer(command)
|
||||
self._out = output.encode()
|
||||
|
||||
def settimeout(self, timeout):
|
||||
pass
|
||||
|
||||
def sendall(self, data):
|
||||
self._run.stdin += data
|
||||
|
||||
def shutdown_write(self):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def recv_ready(self):
|
||||
return bool(self._out)
|
||||
|
||||
def recv(self, size):
|
||||
chunk, self._out = self._out[:size], self._out[size:]
|
||||
return chunk
|
||||
|
||||
def recv_stderr_ready(self):
|
||||
return False
|
||||
|
||||
def exit_status_ready(self):
|
||||
return not self._out
|
||||
|
||||
def recv_exit_status(self):
|
||||
return self._status
|
||||
|
||||
|
||||
def _shell(command: str, **_kwargs) -> str:
|
||||
"""The interactive shell, for what the fixes still ask it."""
|
||||
if command.startswith("command -v apt"):
|
||||
return "/usr/bin/apt"
|
||||
if command.startswith("cat /etc/snmp/snmpd.conf"):
|
||||
return "agentAddress udp:161\nrocommunity public\n"
|
||||
if command.startswith("cat /etc/apt/apt.conf.d/00proxy"):
|
||||
return 'Acquire::http::Proxy "http://proxy:3142";'
|
||||
if command.startswith("snmpget"):
|
||||
return "STRING: Linux"
|
||||
return ""
|
||||
|
||||
|
||||
def _driver(host: Host) -> LinuxDriver:
|
||||
driver = LinuxDriver(
|
||||
"h",
|
||||
"admin",
|
||||
"p",
|
||||
optional_args={"sudo_password": _PW, "apt_proxy_url": "http://proxy:3142"},
|
||||
)
|
||||
driver._device = MagicMock()
|
||||
driver._device.send_command.side_effect = _shell
|
||||
driver._device.remote_conn_pre = SimpleNamespace(get_transport=lambda: host)
|
||||
driver._root = False
|
||||
driver._pkg_manager = "apt"
|
||||
return driver
|
||||
|
||||
|
||||
def _every_line(driver: LinuxDriver, host: Host) -> list[str]:
|
||||
typed = [c.args[0] for c in driver._device.send_command.call_args_list]
|
||||
return typed + [run.line for run in host.runs]
|
||||
|
||||
|
||||
ACTIONS = [
|
||||
("_action_fix_snmp", "/etc/snmp/snmpd.conf", b"agentAddress udp:161"),
|
||||
(
|
||||
"_action_fix_apt_proxy",
|
||||
"/etc/apt/apt.conf.d/00proxy",
|
||||
b'Acquire::http::Proxy "http://proxy:3142";',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_waiting():
|
||||
with patch("time.sleep"):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action, target, content", ACTIONS)
|
||||
class TestStagedWrite:
|
||||
def test_the_old_fixed_path_is_never_touched(self, action, target, content):
|
||||
host = Host()
|
||||
driver = _driver(host)
|
||||
|
||||
getattr(driver, action)()
|
||||
|
||||
for planted in _PLANTED:
|
||||
assert not any(planted in line for line in _every_line(driver, host))
|
||||
|
||||
def test_the_content_is_staged_unprivileged_in_a_fresh_private_file(
|
||||
self, action, target, content
|
||||
):
|
||||
host = Host()
|
||||
driver = _driver(host)
|
||||
|
||||
getattr(driver, action)()
|
||||
|
||||
stage = next(run for run in host.unprivileged if "mktemp" in run.line)
|
||||
assert "umask 077" in stage.line
|
||||
assert "mktemp /tmp/netork-stage.XXXXXX" in stage.line
|
||||
assert content in stage.stdin
|
||||
|
||||
def test_root_installs_a_new_file_and_renames_it_over_the_target(self, action, target, content):
|
||||
host = Host()
|
||||
driver = _driver(host)
|
||||
|
||||
getattr(driver, action)()
|
||||
|
||||
install = next(script for script in host.root_scripts if "install -m" in script)
|
||||
part = f"{target}.netork-tmp"
|
||||
assert f"install -m 644 -o root -g root -- {_STAGED} {part}" in install
|
||||
assert f"mv -f -- {part} {target}" in install
|
||||
assert f"rm -f -- {_STAGED} {part}" in install
|
||||
|
||||
def test_nothing_root_runs_changes_owner_or_mode_of_a_path(self, action, target, content):
|
||||
host = Host()
|
||||
driver = _driver(host)
|
||||
|
||||
getattr(driver, action)()
|
||||
|
||||
assert not any(re.search(r"\bch(own|mod)\b", script) for script in host.root_scripts)
|
||||
|
||||
def test_nothing_runs_as_root_when_staging_failed(self, action, target, content):
|
||||
host = Host(stage_output="mktemp: No space left on device\n", stage_status=1)
|
||||
driver = _driver(host)
|
||||
|
||||
getattr(driver, action)()
|
||||
|
||||
assert not any("install -m" in script for script in host.root_scripts)
|
||||
|
||||
def test_a_staged_path_it_did_not_ask_for_is_refused(self, action, target, content):
|
||||
host = Host(stage_output="/etc/shadow")
|
||||
driver = _driver(host)
|
||||
|
||||
getattr(driver, action)()
|
||||
|
||||
assert not any("/etc/shadow" in script for script in host.root_scripts)
|
||||
|
||||
|
||||
def test_a_failed_install_is_reported():
|
||||
host = Host()
|
||||
host.answer = lambda command: ( # type: ignore[method-assign]
|
||||
(_STAGED, 0) if "mktemp" in command else ("install: cannot create\n", 1)
|
||||
)
|
||||
driver = _driver(host)
|
||||
driver._device.send_command.side_effect = lambda command, **_k: ""
|
||||
|
||||
result = driver._action_fix_apt_proxy()
|
||||
|
||||
assert result["success"] is False
|
||||
assert "install: cannot create" in result["output"]
|
||||
|
||||
|
||||
class TestWireGuardWithoutASudoPassword:
|
||||
"""It went through ``/tmp/.netork_wg_<hash>``: a name anyone can predict, and
|
||||
a file holding the interfaces' private keys."""
|
||||
|
||||
def test_no_temp_file_and_the_dump_comes_from_the_exec_channel(self):
|
||||
host = Host()
|
||||
dump = (
|
||||
"wg0\tPRIV\tPUB\t51820\toff\nwg0\tPEER\t(none)\t1.2.3.4:1\t10.0.0.2/32\t0\t1\t2\toff\n"
|
||||
)
|
||||
host.answer = lambda command: (dump, 0) # type: ignore[method-assign]
|
||||
driver = _driver(host)
|
||||
driver._sudo_password = None
|
||||
|
||||
tunnels = driver.get_vpn_tunnels()
|
||||
|
||||
assert list(tunnels) == ["wireguard-wg0-PEER"]
|
||||
assert not any("/tmp" in line for line in _every_line(driver, host))
|
||||
assert host.runs[0].line.startswith("sudo -n sh -c ")
|
||||
Reference in new issue
Block a user