Home | History | Annotate | Download | only in security_SandboxedServices
      1 # Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
      2 # Use of this source code is governed by a BSD-style license that can be
      3 # found in the LICENSE file.
      4 
      5 import csv
      6 import logging
      7 import os
      8 
      9 from collections import namedtuple, defaultdict
     10 
     11 from autotest_lib.client.bin import test
     12 from autotest_lib.client.common_lib import error
     13 from autotest_lib.client.common_lib import utils
     14 from autotest_lib.client.cros import asan
     15 
     16 
     17 PS_FIELDS = (
     18     'pid',
     19     'ppid',
     20     'comm:32',
     21     'euser:%(usermax)d',
     22     'ruser:%(usermax)d',
     23     'egroup:%(groupmax)d',
     24     'rgroup:%(groupmax)d',
     25     'ipcns',
     26     'mntns',
     27     'netns',
     28     'pidns',
     29     'userns',
     30     'utsns',
     31     'args',
     32 )
     33 # These fields aren't available via ps, so we have to get them indirectly.
     34 # Note: Case is significant as the fields match the /proc/PID/status file.
     35 STATUS_FIELDS = (
     36     'CapInh',
     37     'CapPrm',
     38     'CapEff',
     39     'CapBnd',
     40     'CapAmb',
     41     'NoNewPrivs',
     42     'Seccomp',
     43 )
     44 # These fields are not available via ps or /proc/PID/status.
     45 EXTRA_FIELDS = (
     46     'mountinfo',
     47 )
     48 PsOutput = namedtuple("PsOutput",
     49                       ' '.join([field.split(':')[0].lower() for field in
     50                                 PS_FIELDS + STATUS_FIELDS + EXTRA_FIELDS]))
     51 
     52 # Constants that match the values in /proc/PID/status Seccomp field.
     53 # See `man 5 proc` for more details.
     54 SECCOMP_MODE_DISABLED = '0'
     55 SECCOMP_MODE_STRICT = '1'
     56 SECCOMP_MODE_FILTER = '2'
     57 # For human readable strings.
     58 SECCOMP_MAP = {
     59     SECCOMP_MODE_DISABLED: 'disabled',
     60     SECCOMP_MODE_STRICT: 'strict',
     61     SECCOMP_MODE_FILTER: 'filter',
     62 }
     63 
     64 # These mounts only occur in test images. They should be limited to the init
     65 # mount namespace, so no service should have them.
     66 TEST_IMAGE_MOUNTS = (
     67     '/usr/local',
     68     '/var/db/pkg',
     69     '/var/lib/portage',
     70 )
     71 
     72 
     73 def get_properties(service, init_process):
     74     """Returns a dictionary of the properties of a service.
     75 
     76     @param service: the PsOutput of the service.
     77     @param init_process: the PsOutput of the init process.
     78     """
     79 
     80     properties = dict(service._asdict())
     81     properties['exe'] = service.comm
     82     properties['pidns'] = yes_or_no(service.pidns != init_process.pidns)
     83     properties['mntns'] = yes_or_no(service.mntns != init_process.mntns)
     84     properties['caps'] = yes_or_no(service.capeff != init_process.capeff)
     85     properties['nonewprivs'] = yes_or_no(service.nonewprivs == '1')
     86     properties['filter'] = yes_or_no(service.seccomp == SECCOMP_MODE_FILTER)
     87     return properties
     88 
     89 
     90 def yes_or_no(value):
     91     """Returns 'Yes' or 'No' based on the truthiness of a value.
     92 
     93     @param value: boolean value.
     94     """
     95 
     96     return 'Yes' if value else 'No'
     97 
     98 
     99 def get_mount_info(pid):
    100     """Returns the contents of /proc/PID/mountinfo.
    101 
    102     @param pid: The process id.
    103     """
    104     try:
    105         return tuple(utils.get_mount_info(process=pid))
    106     except IOError as e:
    107         # This process might have died already.
    108         logging.warning('Failed to read mountinfo for pid %s: %s', pid, e)
    109         return ()
    110 
    111 
    112 def has_test_image_mounts(mountinfo):
    113     """Returns whether a process has test image mounts in its mount namespace.
    114 
    115     @param mountinfo: A list of utils.MountInfo.
    116     """
    117     return any(m.mount_point in TEST_IMAGE_MOUNTS for m in mountinfo)
    118 
    119 
    120 class security_SandboxedServices(test.test):
    121     """Enforces sandboxing restrictions on the processes running
    122     on the system.
    123     """
    124 
    125     version = 1
    126 
    127 
    128     def get_running_processes(self):
    129         """Returns a list of running processes as PsOutput objects."""
    130 
    131         usermax = utils.system_output("cut -d: -f1 /etc/passwd | wc -L",
    132                                       ignore_status=True)
    133         groupmax = utils.system_output('cut -d: -f1 /etc/group | wc -L',
    134                                        ignore_status=True)
    135         # Even if the names are all short, make sure we have enough space
    136         # to hold numeric 32-bit ids too (can come up with userns).
    137         usermax = max(int(usermax), 10)
    138         groupmax = max(int(groupmax), 10)
    139         fields = {
    140             'usermax': usermax,
    141             'groupmax': groupmax,
    142         }
    143         ps_cmd = ('ps --no-headers -ww -eo ' +
    144                   (','.join(PS_FIELDS) % fields))
    145         ps_fields_len = len(PS_FIELDS)
    146 
    147         output = utils.system_output(ps_cmd)
    148         logging.debug('output of ps:\n%s', output)
    149 
    150         # Fill in fields that `ps` doesn't support but are in /proc/PID/status.
    151         # Example line output:
    152         # Pid:1 CapInh:0000000000000000 CapPrm:0000001fffffffff CapEff:0000001fffffffff CapBnd:0000001fffffffff Seccomp:0
    153         cmd = (
    154             "for f in /proc/[1-9]*/status ; do awk '$1 ~ \"^(Pid|%s):\" "
    155             "{printf \"%%s%%s \", $1, $NF; if ($1 == \"%s:\") printf \"\\n\"}'"
    156             " $f ; done"
    157         ) % ('|'.join(STATUS_FIELDS), STATUS_FIELDS[-1])
    158         # Processes might exit while awk is running, so ignore its exit status.
    159         status_output = utils.system_output(cmd, ignore_status=True)
    160         # Turn each line into a dict.
    161         # [
    162         #   {'pid': '1', 'CapInh': '0000000000000000', 'Seccomp': '0', ...},
    163         #   {'pid': '10', ...},
    164         #   ...,
    165         # ]
    166         status_list = list(dict(attr.split(':', 1) for attr in line.split())
    167                            for line in status_output.splitlines())
    168         # Create a dict mapping a pid to its extended status data.
    169         # {
    170         #   '1': {'pid': '1', 'CapInh': '0000000000000000', ...},
    171         #   '2': {'pid': '2', ...},
    172         #   ...,
    173         # }
    174         status_data = dict((x['Pid'], x) for x in status_list)
    175         logging.debug('output of awk:\n%s', status_output)
    176 
    177         # Now merge the two sets of process data.
    178         running_processes = []
    179         for line in output.splitlines():
    180             # crbug.com/422700: Filter out zombie processes.
    181             if '<defunct>' in line:
    182                 continue
    183 
    184             fields = line.split(None, ps_fields_len - 1)
    185             pid = fields[0]
    186             # The process lists might not be exactly the same (since we gathered
    187             # data with multiple commands), and not all fields might exist (e.g.
    188             # older kernels might not have all the fields).
    189             pid_data = status_data.get(pid, {})
    190             status_fields = [pid_data.get(key) for key in STATUS_FIELDS]
    191             extra_fields = [get_mount_info(pid)]
    192             running_processes.append(
    193                 PsOutput(*fields + status_fields + extra_fields))
    194 
    195         return running_processes
    196 
    197 
    198     def load_baseline(self):
    199         """The baseline file lists the services we know and
    200         whether (and how) they are sandboxed.
    201         """
    202 
    203         def load(path):
    204             """Load baseline from |path| and return its fields and dictionary.
    205 
    206             @param path: The baseline to load.
    207             """
    208             logging.info('Loading baseline %s', path)
    209             reader = csv.DictReader(open(path))
    210             return reader.fieldnames, dict((d['exe'], d) for d in reader
    211                                            if not d['exe'].startswith('#'))
    212 
    213         baseline_path = os.path.join(self.bindir, 'baseline')
    214         fields, ret = load(baseline_path)
    215 
    216         board = utils.get_current_board()
    217         baseline_path += '.' + board
    218         if os.path.exists(baseline_path):
    219             new_fields, new_entries = load(baseline_path)
    220             if new_fields != fields:
    221                 raise error.TestError('header mismatch in %s' % baseline_path)
    222             ret.update(new_entries)
    223 
    224         return fields, ret
    225 
    226 
    227     def load_exclusions(self):
    228         """The exclusions file lists running programs
    229         that we don't care about (for now).
    230         """
    231 
    232         exclusions_path = os.path.join(self.bindir, 'exclude')
    233         return set(line.strip() for line in open(exclusions_path)
    234                    if not line.startswith('#'))
    235 
    236 
    237     def dump_services(self, fieldnames, running_services_properties):
    238         """Leaves a list of running services in the results dir
    239         so that we can update the baseline file if necessary.
    240 
    241         @param fieldnames: list of fields to be written.
    242         @param running_services_properties: list of services to be logged.
    243         """
    244 
    245         file_path = os.path.join(self.resultsdir, 'running_services')
    246         with open(file_path, 'w') as output_file:
    247             writer = csv.DictWriter(output_file, fieldnames=fieldnames,
    248                                     extrasaction='ignore')
    249             writer.writeheader()
    250             for service_properties in running_services_properties:
    251                 writer.writerow(service_properties)
    252 
    253 
    254     def run_once(self):
    255         """Inspects the process list, looking for root and sandboxed processes
    256         (with some exclusions). If we have a baseline entry for a given process,
    257         confirms it's an exact match. Warns if we see root or sandboxed
    258         processes that we have no baseline for, and warns if we have
    259         baselines for processes not seen running.
    260         """
    261 
    262         fieldnames, baseline = self.load_baseline()
    263         exclusions = self.load_exclusions()
    264         running_processes = self.get_running_processes()
    265         is_asan = asan.running_on_asan()
    266         if is_asan:
    267             logging.info('ASAN image detected -> skipping seccomp checks')
    268 
    269         kthreadd_pid = -1
    270 
    271         init_process = None
    272         running_services = {}
    273 
    274         # Filter running processes list.
    275         for process in running_processes:
    276             exe = process.comm
    277 
    278             if exe == "kthreadd":
    279                 kthreadd_pid = process.pid
    280                 continue
    281             elif process.pid == "1":
    282                 init_process = process
    283                 continue
    284 
    285             # Don't worry about kernel threads.
    286             if process.ppid == kthreadd_pid:
    287                 continue
    288 
    289             if exe in exclusions:
    290                 continue
    291 
    292             running_services[exe] = process
    293 
    294         if not init_process:
    295             raise error.TestFail("Cannot find init process")
    296 
    297         # Find differences between running services and baseline.
    298         services_set = set(running_services.keys())
    299         baseline_set = set(baseline.keys())
    300 
    301         new_services = services_set.difference(baseline_set)
    302         stale_baselines = defaultdict(list)
    303 
    304         for exe in baseline_set.difference(services_set):
    305             stale_baselines[exe].append('unused')
    306 
    307         # Check baseline.
    308         sandbox_delta = defaultdict(list)
    309         for exe in services_set.intersection(baseline_set):
    310             process = running_services[exe]
    311             stale_flags = []
    312             errors = []
    313 
    314             # If the process is not running as the correct user.
    315             if process.euser != baseline[exe]["euser"]:
    316                 errors.append('bad user: wanted "%s" but got "%s"' %
    317                               (baseline[exe]['euser'], process.euser))
    318 
    319             # If the process is not running as the correct group.
    320             if process.egroup != baseline[exe]['egroup']:
    321                 errors.append('bad group: wanted "%s" but got "%s"' %
    322                               (baseline[exe]['egroup'], process.egroup))
    323 
    324             # Check the various sandbox settings.
    325             if process.pidns == init_process.pidns:
    326                 if baseline[exe]['pidns'] == 'Yes':
    327                     errors.append('missing pid ns usage')
    328             elif baseline[exe]['pidns'] != 'Yes':
    329                 stale_flags.append('pidns')
    330 
    331             if process.mntns == init_process.mntns:
    332                 if baseline[exe]['mntns'] == 'Yes':
    333                     errors.append('missing mount ns usage')
    334             elif has_test_image_mounts(process.mountinfo):
    335                 if baseline[exe]['mntns'] == 'Yes':
    336                     errors.append('did not call pivot_root(2)')
    337             elif baseline[exe]['mntns'] != 'Yes':
    338                 stale_flags.append('mntns')
    339 
    340             if process.capeff == init_process.capeff:
    341                 if baseline[exe]['caps'] == 'Yes':
    342                     errors.append('missing caps usage')
    343             elif baseline[exe]['caps'] != 'Yes':
    344                 stale_flags.append('caps')
    345 
    346             if process.nonewprivs != '1':
    347                 if baseline[exe]['nonewprivs'] == 'Yes':
    348                     errors.append('missing NoNewPrivs')
    349             elif baseline[exe]['nonewprivs'] != 'Yes':
    350                 stale_flags.append('nonewprivs')
    351 
    352             if not is_asan:
    353                 # Since Minijail disables seccomp at runtime when ASAN is
    354                 # active, we can't enforce it on ASAN bots.  Just ignore
    355                 # the test entirely.  (Comment applies to "is_asan" above.)
    356                 if process.seccomp != SECCOMP_MODE_FILTER:
    357                     if baseline[exe]['filter'] == 'Yes':
    358                         errors.append(
    359                             'missing seccomp usage: '
    360                             'wanted %s (%s) but got %s (%s)' %
    361                             (SECCOMP_MODE_FILTER,
    362                              SECCOMP_MAP[SECCOMP_MODE_FILTER], process.seccomp,
    363                              SECCOMP_MAP.get(process.seccomp, '???')))
    364                 elif baseline[exe]['filter'] != 'Yes':
    365                     stale_flags.append('filter')
    366 
    367             if stale_flags:
    368                 stale_baselines[exe].append('potentially missing flags: %s' %
    369                                             ','.join(stale_flags))
    370             if errors:
    371                 sandbox_delta[exe].extend(errors)
    372 
    373         # Save current run to results dir.
    374         running_services_properties = [get_properties(s, init_process)
    375                                        for s in running_services.values()]
    376         self.dump_services(fieldnames, running_services_properties)
    377 
    378         if len(stale_baselines) > 0:
    379             logging.warn('Stale baselines: %r', stale_baselines)
    380 
    381         if len(new_services) > 0:
    382             logging.warn('New services: %r', new_services)
    383 
    384             # We won't complain about new non-root services (on the assumption
    385             # that they've already somewhat sandboxed things), but we'll fail
    386             # with new root services (on the assumption they haven't done any
    387             # sandboxing work).  If they really need to run as root, they can
    388             # update the baseline to whitelist it.
    389             for exe in new_services:
    390                 if running_services[exe].euser == 'root':
    391                     sandbox_delta[exe].append('missing euser')
    392 
    393         if len(sandbox_delta) > 0:
    394             for delta_entry in sandbox_delta:
    395                 logging.error('Failed sandboxing: %s', delta_entry)
    396             raise error.TestFail('One or more processes failed sandboxing: %r' %
    397                                  sandbox_delta)
    398