Add support for Linux capabilities

This change adds a new `capabilities` kwarg to PrivContext, which
specifies the Linux capabilities to retain on the privileged side of
this context.  This allows the privileged daemon to be run as root but
with restricted permissions, or as not-root but still with some limited
superpowers.

A new `capabilities` config option is added to the context config
section that overrides the default capabilities for that context.  It is
expected that this will rarely be used.

Note that there is intentionally no way to specify "I want all
capabilities".

Change-Id: I61169d1d27609deb04115f4119654fd3d0690357
This commit is contained in:
Angus Lees 2015-11-06 15:55:20 +11:00
parent 55cb0695bd
commit 5a00350935
7 changed files with 344 additions and 11 deletions

View File

@ -0,0 +1,145 @@
# Copyright 2015 Rackspace Hosting
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import os
import cffi
# Expand as necessary
CAP_CHOWN = 0
CAP_DAC_OVERRIDE = 1
CAP_FOWNER = 3
CAP_KILL = 5
CAP_SETPCAP = 8
CAP_NET_BIND_SERVICE = 10
CAP_NET_BROADCAST = 11
CAP_NET_ADMIN = 12
CAP_NET_RAW = 13
CAP_SYS_ADMIN = 21
# Convenience dicts for human readable values
CAPS_BYNAME = {}
CAPS_BYVALUE = {}
for k, v in globals().copy().items():
if k.startswith('CAP_'):
CAPS_BYNAME[k] = v
CAPS_BYVALUE[v] = k
CDEF = '''
/* Edited highlights from `echo '#include <sys/capability.h>' | gcc -E -` */
#define _LINUX_CAPABILITY_VERSION_2 0x20071026
#define _LINUX_CAPABILITY_U32S_2 2
typedef unsigned int __u32;
typedef struct __user_cap_header_struct {
__u32 version;
int pid;
} *cap_user_header_t;
typedef struct __user_cap_data_struct {
__u32 effective;
__u32 permitted;
__u32 inheritable;
} *cap_user_data_t;
int capset(cap_user_header_t header, const cap_user_data_t data);
int capget(cap_user_header_t header, cap_user_data_t data);
/* Edited highlights from `echo '#include <sys/prctl.h>' | gcc -E -` */
#define PR_GET_KEEPCAPS 7
#define PR_SET_KEEPCAPS 8
int prctl (int __option, ...);
'''
ffi = cffi.FFI()
crt = ffi.dlopen(None)
ffi.cdef(CDEF)
# mock.patching crt.* directly seems to upset cffi. Use an
# indirection point here for easier testing.
_prctl = crt.prctl
_capget = crt.capget
_capset = crt.capset
def set_keepcaps(enable):
"""Set/unset thread's "keep capabilities" flag - see prctl(2)"""
ret = _prctl(crt.PR_SET_KEEPCAPS,
ffi.cast('unsigned long', bool(enable)))
if ret != 0:
errno = ffi.errno
raise OSError(errno, os.strerror(errno))
def drop_all_caps_except(effective, permitted, inheritable):
"""Set (effective, permitted, inheritable) to provided list of caps"""
eff = _caps_to_mask(effective)
prm = _caps_to_mask(permitted)
inh = _caps_to_mask(inheritable)
header = ffi.new('cap_user_header_t',
{'version': crt._LINUX_CAPABILITY_VERSION_2,
'pid': 0})
data = ffi.new('struct __user_cap_data_struct[2]')
data[0].effective = eff & 0xffffffff
data[1].effective = eff >> 32
data[0].permitted = prm & 0xffffffff
data[1].permitted = prm >> 32
data[0].inheritable = inh & 0xffffffff
data[1].inheritable = inh >> 32
ret = _capset(header, data)
if ret != 0:
errno = ffi.errno
raise OSError(errno, os.strerror(errno))
def _mask_to_caps(mask):
"""Convert bitmask to list of set bit offsets"""
return [i for i in range(64) if (1 << i) & mask]
def _caps_to_mask(caps):
"""Convert list of bit offsets to bitmask"""
mask = 0
for cap in caps:
mask |= 1 << cap
return mask
def get_caps():
"""Return (effective, permitted, inheritable) as lists of caps"""
header = ffi.new('cap_user_header_t',
{'version': crt._LINUX_CAPABILITY_VERSION_2,
'pid': 0})
data = ffi.new('struct __user_cap_data_struct[2]')
ret = _capget(header, data)
if ret != 0:
errno = ffi.errno
raise OSError(errno, os.strerror(errno))
return (
_mask_to_caps(data[0].effective |
(data[1].effective << 32)),
_mask_to_caps(data[0].permitted |
(data[1].permitted << 32)),
_mask_to_caps(data[0].inheritable |
(data[1].inheritable << 32)),
)

View File

@ -62,6 +62,7 @@ from oslo_config import cfg
from oslo_log import log as logging
from oslo_utils import importutils
from oslo_privsep import capabilities
from oslo_privsep import comm
from oslo_privsep._i18n import _, _LE, _LI
@ -322,6 +323,7 @@ class Daemon(object):
self.context = context
self.user = context.conf.user
self.group = context.conf.group
self.caps = set(context.conf.capabilities)
def run(self):
"""Run request loop. Sets up environment, then calls loop()"""
@ -339,19 +341,48 @@ class Daemon(object):
# stderr is left untouched
def _drop_privs(self):
if self.group is not None:
try:
os.setgroups([])
except OSError:
msg = _('Failed to remove supplemental groups')
LOG.critical(msg)
raise FailedToDropPrivileges(msg)
if self.user is not None:
setuid(self.user)
try:
# Keep current capabilities across setuid away from root.
capabilities.set_keepcaps(True)
if self.group is not None:
try:
os.setgroups([])
except OSError:
msg = _('Failed to remove supplemental groups')
LOG.critical(msg)
raise FailedToDropPrivileges(msg)
if self.user is not None:
setuid(self.user)
if self.group is not None:
setgid(self.group)
finally:
capabilities.set_keepcaps(False)
LOG.info(_LI('privsep process running with uid/gid: %(uid)s/%(gid)s'),
{'uid': os.getuid(), 'gid': os.getgid()})
capabilities.drop_all_caps_except(self.caps, self.caps, [])
def fmt_caps(capset):
if not capset:
return 'none'
return '|'.join(sorted(capabilities.CAPS_BYVALUE[c]
for c in capset))
eff, prm, inh = capabilities.get_caps()
LOG.info(
_LI('privsep process running with capabilities '
'(eff/prm/inh): %(eff)s/%(prm)s/%(inh)s'),
{
'eff': fmt_caps(eff),
'prm': fmt_caps(prm),
'inh': fmt_caps(inh),
})
def _process_cmd(self, cmd, *args):
if cmd == Message.PING:
return (Message.PONG.value,)

View File

@ -17,19 +17,34 @@ import enum
import functools
from oslo_config import cfg
from oslo_config import types
from oslo_log import log as logging
from oslo_privsep import capabilities
from oslo_privsep import daemon
from oslo_privsep._i18n import _, _LW
LOG = logging.getLogger(__name__)
def CapNameOrInt(value):
value = str(value).strip()
try:
return capabilities.CAPS_BYNAME[value]
except KeyError:
return int(value)
OPTS = [
cfg.StrOpt('user',
help=_('User that the privsep daemon should run as.')),
cfg.StrOpt('group',
help=_('Group that the privsep daemon should run as.')),
cfg.Opt('capabilities',
type=types.List(CapNameOrInt), default=[],
help=_('List of Linux capabilities retained by the privsep '
'daemon.')),
cfg.StrOpt('helper_command',
default=('sudo privsep-helper'
# TODO(gus): how do I find a good config path?
@ -48,7 +63,19 @@ class Method(enum.Enum):
class PrivContext(object):
def __init__(self, prefix, cfg_section='privsep', pypath=None):
def __init__(self, prefix, cfg_section='privsep', pypath=None,
capabilities=None):
# Note that capabilities=[] means retaining no capabilities
# and leaves even uid=0 with no powers except being able to
# read/write to the filesystem as uid=0. This might be what
# you want, but probably isn't.
#
# There is intentionally no way to say "I want all the
# capabilities."
if capabilities is None:
raise ValueError('capabilities is a required parameter')
self.pypath = pypath
self.prefix = prefix
self.cfg_section = cfg_section
@ -56,6 +83,8 @@ class PrivContext(object):
self.channel = None
cfg.CONF.register_opts(OPTS, group=cfg_section)
cfg.CONF.set_default('capabilities', group=cfg_section,
default=capabilities)
@property
def conf(self):

View File

@ -0,0 +1,88 @@
# Copyright 2015 Rackspace Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import mock
from oslotest import base
from oslo_privsep import capabilities
class TestCapabilities(base.BaseTestCase):
@mock.patch('oslo_privsep.capabilities._prctl')
def test_set_keepcaps_error(self, mock_prctl):
mock_prctl.return_value = -1
self.assertRaises(OSError, capabilities.set_keepcaps, True)
@mock.patch('oslo_privsep.capabilities._prctl')
def test_set_keepcaps(self, mock_prctl):
mock_prctl.return_value = 0
capabilities.set_keepcaps(True)
# Disappointingly, ffi.cast(type, 1) != ffi.cast(type, 1)
# so can't just use assert_called_once_with :-(
self.assertEqual(1, mock_prctl.call_count)
self.assertItemsEqual(
[8, 1], # [PR_SET_KEEPCAPS, true]
[int(x) for x in mock_prctl.call_args[0]])
@mock.patch('oslo_privsep.capabilities._capset')
def test_drop_all_caps_except_error(self, mock_capset):
mock_capset.return_value = -1
self.assertRaises(
OSError, capabilities.drop_all_caps_except, [0], [0], [0])
@mock.patch('oslo_privsep.capabilities._capset')
def test_drop_all_caps_except(self, mock_capset):
mock_capset.return_value = 0
# Somewhat arbitrary bit patterns to exercise _caps_to_mask
capabilities.drop_all_caps_except(
(17, 24, 49), (8, 10, 35, 56), (24, 31, 40))
self.assertEqual(1, mock_capset.call_count)
hdr, data = mock_capset.call_args[0]
self.assertEqual(0x20071026, # _LINUX_CAPABILITY_VERSION_2
hdr.version)
self.assertEqual(0x01020000, data[0].effective)
self.assertEqual(0x00020000, data[1].effective)
self.assertEqual(0x00000500, data[0].permitted)
self.assertEqual(0x01000008, data[1].permitted)
self.assertEqual(0x81000000, data[0].inheritable)
self.assertEqual(0x00000100, data[1].inheritable)
@mock.patch('oslo_privsep.capabilities._capget')
def test_get_caps_error(self, mock_capget):
mock_capget.return_value = -1
self.assertRaises(OSError, capabilities.get_caps)
@mock.patch('oslo_privsep.capabilities._capget')
def test_get_caps(self, mock_capget):
def impl(hdr, data):
# Somewhat arbitrary bit patterns to exercise _mask_to_caps
data[0].effective = 0x01020000
data[1].effective = 0x00020000
data[0].permitted = 0x00000500
data[1].permitted = 0x01000008
data[0].inheritable = 0x81000000
data[1].inheritable = 0x00000100
return 0
mock_capget.side_effect = impl
self.assertItemsEqual(
([17, 24, 49],
[8, 10, 35, 56],
[24, 31, 40]),
capabilities.get_caps())

View File

@ -13,10 +13,14 @@
# under the License.
import fixtures
import mock
import time
from oslo_log import log as logging
from oslotest import base
from oslo_privsep import capabilities
from oslo_privsep import daemon
from oslo_privsep.tests import testctx
@ -53,7 +57,40 @@ class LogTest(testctx.TestContextTestCase):
self.assertIn('test@WARN', self.logger.output)
class TestDaemon(testctx.TestContextTestCase):
class TestDaemon(base.BaseTestCase):
@mock.patch('os.setuid')
@mock.patch('os.setgid')
@mock.patch('os.setgroups')
@mock.patch('oslo_privsep.capabilities.set_keepcaps')
@mock.patch('oslo_privsep.capabilities.drop_all_caps_except')
def test_drop_privs(self, mock_dropcaps, mock_keepcaps,
mock_setgroups, mock_setgid, mock_setuid):
channel = mock.NonCallableMock()
context = mock.NonCallableMock()
context.conf.user = 42
context.conf.group = 84
context.conf.capabilities = [
capabilities.CAP_SYS_ADMIN, capabilities.CAP_NET_ADMIN]
d = daemon.Daemon(channel, context)
d._drop_privs()
mock_setuid.assert_called_once_with(42)
mock_setgid.assert_called_once_with(84)
mock_setgroups.assert_called_once_with([])
self.assertItemsEqual(
[mock.call(True), mock.call(False)],
mock_keepcaps.mock_calls)
mock_dropcaps.assert_called_once_with(
set((capabilities.CAP_SYS_ADMIN, capabilities.CAP_NET_ADMIN)),
set((capabilities.CAP_SYS_ADMIN, capabilities.CAP_NET_ADMIN)),
[])
class TestWithContext(testctx.TestContextTestCase):
def test_unexported(self):
self.assertRaisesRegexp(

View File

@ -25,6 +25,8 @@ context = priv_context.PrivContext(
# This context allows entrypoints anywhere below oslo_privsep.tests.
oslo_privsep.tests.__name__,
pypath=__name__ + '.context',
# This is one of the rare cases where we actually want zero powers:
capabilities=[],
)

View File

@ -8,3 +8,4 @@ oslo.i18n>=1.5.0 # Apache-2.0
oslo.config>=2.6.0 # Apache-2.0
oslo.utils>=2.4.0,!=2.6.0 # Apache-2.0
enum34;python_version=='2.7' or python_version=='2.6'
cffi