Home | History | Annotate | Download | only in security_DbusMap
      1 # Copyright (c) 2011 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 dbus
      6 import json
      7 import logging
      8 import os.path
      9 from xml.dom.minidom import parse, parseString
     10 
     11 from autotest_lib.client.bin import test, utils
     12 from autotest_lib.client.common_lib import error
     13 from autotest_lib.client.cros import constants, login
     14 
     15 class security_DbusMap(test.test):
     16     version = 2
     17 
     18     def policy_sort_priority(self, policy):
     19         """
     20         Given a DOMElement representing one <policy> block from a dbus
     21         configuraiton file, return a number suitable for determining
     22         the order in which this policy would be applied by dbus-daemon.
     23         For example, by returning:
     24         0 for 'default' policies
     25         1 for 'group' policies
     26         2 for 'user' policies
     27         ... these numbers can be used as a sort-key for sorting
     28         an array of policies into the order they would be evaluated by
     29         dbus-daemon.
     30         """
     31         # As derived from dbus-daemon(1) manpage
     32         if policy.getAttribute('context') == 'default':
     33             return 0
     34         if policy.getAttribute('group') != '':
     35             return 1
     36         if policy.getAttribute('user') != '':
     37             return 2
     38         if policy.getAttribute('at_console') == 'true':
     39             return 3
     40         if policy.getAttribute('at_console') == 'false':
     41             return 4
     42         if policy.getAttribute('context') == 'mandatory':
     43             return 5
     44 
     45 
     46     def sort_policies(self, policies):
     47         """
     48         Given an array of DOMElements representing <policy> blocks,
     49         return a sorted copy of the array. Sorting is determined by
     50         the order in which dbus-daemon(1) would consider the rules.
     51         This is a stable sort, so in cases where dbus would employ
     52         "last rule wins," position in the input list will be honored.
     53         """
     54         # Use decorate-sort-undecorate to minimize calls to
     55         # policy_sort_priority(). See http://wiki.python.org/moin/HowTo/Sorting
     56         decorated = [(self.policy_sort_priority(policy), i, policy) for
     57                      i, policy in enumerate(policies)]
     58         decorated.sort()
     59         return [policy for _,_,policy in decorated]
     60 
     61 
     62     def check_policies(self, config_doms, dest, iface, member,
     63                        user='chronos', at_console=None):
     64         """
     65         Given 1 or more xml.dom's representing dbus configuration
     66         data, determine if the <destination, interface, member>
     67         triplet specified in the arguments would be permitted for
     68         the specified user.
     69 
     70         Returns True if permitted, False otherwise.
     71         See also http://dbus.freedesktop.org/doc/busconfig.dtd
     72         """
     73         # In the default case, if the caller doesn't specify
     74         # "at_console," employ this cros-specific heuristic:
     75         if user == 'chronos' and at_console == None:
     76             at_console = True
     77 
     78         # Apply the policies iteratively, in the same order
     79         # that dbus-daemon(1) would consider them.
     80         allow = False
     81         for dom in config_doms:
     82             for buscfg in dom.getElementsByTagName('busconfig'):
     83                 policies = self.sort_policies(
     84                     buscfg.getElementsByTagName('policy'))
     85                 for policy in policies:
     86                     ruling = self.check_one_policy(policy, dest, iface,
     87                                                    member, user, at_console)
     88                     if ruling is not None:
     89                         allow = ruling
     90         return allow
     91 
     92 
     93     def check_one_policy(self, policy, dest, iface, member,
     94                          user='chronos', at_console=True):
     95         """
     96         Given a DOMElement representing one <policy> block from a dbus
     97         configuration file, determine if the <destination, interface,
     98         member> triplet specified in the arguments would be permitted
     99         for the specified user.
    100 
    101         Returns True if permitted, False if prohibited, or
    102         None if the policy does not apply to the triplet.
    103         """
    104         # While D-Bus overall is a default-deny, this individual
    105         # rule may not match, and some previous rule may have already
    106         # said "allow" for this interface/method. So, we work from
    107         # here starting with "doesn't apply," not "deny" to avoid
    108         # falsely masking any previous "allow" rules.
    109         allow = None
    110 
    111         # TODO(jimhebert) group='...' is not currently used by any
    112         # Chrome OS dbus policies but could be in the future so
    113         # we should add a check for it in this if-block.
    114         if ((policy.getAttribute('context') != 'default') and
    115             (policy.getAttribute('user') != user) and
    116             (policy.getAttribute('at_console') != 'true')):
    117             # In this case, the entire <policy> block does not apply
    118             return None
    119 
    120         # Alternatively, if this IS a at_console policy, but the
    121         # situation being checked is not an "at_console" situation,
    122         # then that's another way the policy would also not apply.
    123         if (policy.getAttribute('at_console') == 'true' and not
    124             at_console):
    125             return None
    126 
    127         # If the <policy> applies, try to find <allow> or <deny>
    128         # child nodes that apply:
    129         for node in policy.childNodes:
    130             if (node.nodeType == node.ELEMENT_NODE and
    131                 node.localName in ['allow','deny']):
    132                 ruling = self.check_one_node(node, dest, iface, member)
    133                 if ruling is not None:
    134                     allow = ruling
    135         return allow
    136 
    137 
    138     def check_one_node(self, node, dest, iface, member):
    139         """
    140         Given a DOMElement representing one <allow> or <deny> tag from a
    141         dbus configuration file, determine if the <destination, interface,
    142         member> triplet specified in the arguments would be permitted.
    143 
    144         Returns True if permitted, False if prohibited, or
    145         None if the policy does not apply to the triplet.
    146         """
    147         # Require send_destination to match (if we accept missing
    148         # send_destination we end up falsely processing tags like
    149         # <allow own="...">). But, do not require send_interface
    150         # or send_member to exist, because omitting them is used
    151         # as a way of wildcarding in dbus configuration.
    152         if ((node.getAttribute('send_destination') == dest) and
    153             (not node.hasAttribute('send_interface') or
    154              node.getAttribute('send_interface') == iface) and
    155             (not node.hasAttribute('send_member') or
    156              node.getAttribute('send_member') == member)):
    157             # The rule applies! Return True if it's an allow rule, else false
    158             logging.debug(('%s send_destination=%s send_interface=%s '
    159                            'send_member=%s applies to %s %s %s.') %
    160                           (node.localName,
    161                            node.getAttribute('send_destination'),
    162                            node.getAttribute('send_interface'),
    163                            node.getAttribute('send_member'),
    164                            dest, iface, member))
    165             return (node.localName == 'allow')
    166         else:
    167             return None
    168 
    169 
    170     def load_dbus_config_doms(self, dbusdir='/etc/dbus-1/system.d'):
    171         """
    172         Given a path to a directory containing valid dbus configuration
    173         files (http://dbus.freedesktop.org/doc/busconfig.dtd), return
    174         a series of parsed DOMs representing the configuration.
    175         This function implements the same requirements as dbus-daemon
    176         itself -- notably, that valid config files must be named
    177         with a ".conf" extension.
    178         Returns: a list of DOMs
    179         """
    180         config_doms = []
    181         for dirent in os.listdir(dbusdir):
    182             dirent = os.path.join(dbusdir, dirent)
    183             if os.path.isfile(dirent) and dirent.endswith('.conf'):
    184                 config_doms.append(parse(dirent))
    185         return config_doms
    186 
    187 
    188     def mutual_compare(self, dbus_list, baseline, context='all'):
    189         """
    190         This is a front-end for compare_dbus_trees which handles
    191         comparison in both directions, discovering not only what is
    192         missing from the baseline, but what is missing from the system.
    193 
    194         The optional 'context' argument is (only) used to for
    195         providing more detailed context in the debug-logging
    196         that occurs.
    197 
    198         Returns: True if the two exactly match. False otherwise.
    199         """
    200         self.sort_dbus_tree(dbus_list)
    201         self.sort_dbus_tree(baseline)
    202 
    203         # Compare trees to find added API's.
    204         newapis = self.compare_dbus_trees(dbus_list, baseline)
    205         if (len(newapis) > 0):
    206             logging.error("New (accessible to %s) API's to review:" % context)
    207             logging.error(json.dumps(newapis, sort_keys=True, indent=2))
    208 
    209         # Swap arguments to find missing API's.
    210         missing_apis = self.compare_dbus_trees(baseline, dbus_list)
    211         if (len(missing_apis) > 0):
    212             logging.error("Missing API's (expected to be accessible to %s):" %
    213                           context)
    214             logging.error(json.dumps(missing_apis, sort_keys=True, indent=2))
    215 
    216         return (len(newapis) + len(missing_apis) == 0)
    217 
    218 
    219     def add_member(self, dbus_list, dest, iface, member):
    220         return self._add_surface(dbus_list, dest, iface, member, 'methods')
    221 
    222 
    223     def add_signal(self, dbus_list, dest, iface, signal):
    224         return self._add_surface(dbus_list, dest, iface, signal, 'signals')
    225 
    226 
    227     def add_property(self, dbus_list, dest, iface, signal):
    228         return self._add_surface(dbus_list, dest, iface, signal, 'properties')
    229 
    230 
    231     def _add_surface(self, dbus_list, dest, iface, member, slot):
    232         """
    233         This can add an entry for a member function to a given
    234         dbus list. It behaves somewhat like "mkdir -p" in that
    235         it creates any missing, necessary intermediate portions
    236         of the data structure. For example, if this is the first
    237         member being added for a given interface, the interface
    238         will not already be mentioned in dbus_list, and this
    239         function initializes the interface dictionary appropriately.
    240         Returns: None
    241         """
    242         # Ensure the Destination object exists in the data structure.
    243         dest_idx = -1
    244         for (i, objdict) in enumerate(dbus_list):
    245             if objdict['Object_name'] == dest:
    246                 dest_idx = i
    247         if dest_idx == -1:
    248             dbus_list.append({'Object_name': dest, 'interfaces': []})
    249 
    250         # Ensure the Interface entry exists for that Destination object.
    251         iface_idx = -1
    252         for (i, ifacedict) in enumerate(dbus_list[dest_idx]['interfaces']):
    253             if ifacedict['interface'] == iface:
    254                 iface_idx = i
    255         if iface_idx == -1:
    256             dbus_list[dest_idx]['interfaces'].append({'interface': iface,
    257                                                       'signals': [],
    258                                                       'properties': [],
    259                                                       'methods': []})
    260 
    261         # Ensure the slot exists.
    262         if not slot in dbus_list[dest_idx]['interfaces'][iface_idx]:
    263             dbus_list[dest_idx]['interfaces'][iface_idx][slot] = []
    264 
    265         # Add member so long as it's not a duplicate.
    266         if not member in (
    267             dbus_list[dest_idx]['interfaces'][iface_idx][slot]):
    268             dbus_list[dest_idx]['interfaces'][iface_idx][slot].append(
    269                 member)
    270 
    271 
    272     def list_baselined_users(self):
    273         """
    274         Return a list of usernames for which we keep user-specific
    275         attack-surface baselines.
    276         """
    277         bdir = os.path.dirname(os.path.abspath(__file__))
    278         users = []
    279         for item in os.listdir(bdir):
    280             # Pick up baseline.username files but ignore emacs backups.
    281             if item.startswith('baseline.') and not item.endswith('~'):
    282                 users.append(item.partition('.')[2])
    283         return users
    284 
    285 
    286     def load_baseline(self, user=''):
    287         """
    288         Return a list of interface names we expect to be owned
    289         by chronos.
    290         """
    291         # The overall baseline is 'baseline'. User-specific baselines are
    292         # stored in files named 'baseline.<username>'.
    293         baseline_name = 'baseline'
    294         if user:
    295             baseline_name = '%s.%s' % (baseline_name, user)
    296 
    297         # Figure out path to baseline file, by looking up our own path.
    298         bpath = os.path.abspath(__file__)
    299         bpath = os.path.join(os.path.dirname(bpath), baseline_name)
    300         return self.load_dbus_data_from_disk(bpath)
    301 
    302 
    303     def write_dbus_data_to_disk(self, dbus_list, file_path):
    304         """Writes the given dbus data to a given path to a json file.
    305         Args:
    306             dbus_list: list of dbus dictionaries to write to disk.
    307             file_path: the path to the file to write the data to.
    308         """
    309         file_handle = open(file_path, 'w')
    310         my_json = json.dumps(dbus_list, sort_keys=True, indent=2)
    311         # The json dumper has a trailing whitespace problem, and lacks
    312         # a final newline. Fix both here.
    313         file_handle.write(my_json.replace(', \n',',\n') + '\n')
    314         file_handle.close()
    315 
    316 
    317     def load_dbus_data_from_disk(self, file_path):
    318         """Loads dbus data from a given path to a json file.
    319         Args:
    320             file_path: path to the file as a string.
    321         Returns:
    322             A list of the dictionary representation of the dbus data loaded.
    323             The dictionary format is the same as returned by walk_object().
    324         """
    325         file_handle = open(file_path, 'r')
    326         dbus_data = json.loads(file_handle.read())
    327         file_handle.close()
    328         return dbus_data
    329 
    330 
    331     def sort_dbus_tree(self, tree):
    332         """Sorts a an aray of dbus dictionaries in alphabetical order.
    333              All levels of the tree are sorted.
    334         Args:
    335             tree: the array to sort. Modified in-place.
    336         """
    337         tree.sort(key=lambda x: x['Object_name'])
    338         for dbus_object in tree:
    339             dbus_object['interfaces'].sort(key=lambda x: x['interface'])
    340             for interface in dbus_object['interfaces']:
    341                 interface['methods'].sort()
    342                 interface['signals'].sort()
    343                 interface['properties'].sort()
    344 
    345 
    346     def compare_dbus_trees(self, current, baseline):
    347         """Compares two dbus dictionaries and return the delta.
    348            The comparison only returns what is in the current (LHS) and not
    349            in the baseline (RHS). If you want the reverse, call again
    350            with the arguments reversed.
    351         Args:
    352             current: dbus tree you want to compare against the baseline.
    353             baseline: dbus tree baseline.
    354         Returns:
    355             A list of dictionary representations of the additional dbus
    356             objects, if there is a difference. Otherwise it returns an
    357             empty list. The format of the dictionaries is the same as the
    358             one returned in walk_object().
    359         """
    360         # Build the key map of what is in the baseline.
    361         bl_object_names = [bl_object['Object_name'] for bl_object in baseline]
    362 
    363         new_items = []
    364         for dbus_object in current:
    365             if dbus_object['Object_name'] in bl_object_names:
    366                 index = bl_object_names.index(dbus_object['Object_name'])
    367                 bl_object_interfaces = baseline[index]['interfaces']
    368                 bl_interface_names = [name['interface'] for name in
    369                                       bl_object_interfaces]
    370 
    371                 # If we have a new interface/method we need to build the shell.
    372                 new_object = {'Object_name':dbus_object['Object_name'],
    373                               'interfaces':[]}
    374 
    375                 for interface in dbus_object['interfaces']:
    376                     if interface['interface'] in bl_interface_names:
    377                         # The interface was baselined, check everything.
    378                         diffslots = {}
    379                         for slot in ['methods', 'signals', 'properties']:
    380                             index = bl_interface_names.index(
    381                                 interface['interface'])
    382                             bl_methods = set(bl_object_interfaces[index][slot])
    383                             methods = set(interface[slot])
    384                             difference = methods.difference(bl_methods)
    385                             diffslots[slot] = list(difference)
    386                         if (diffslots['methods'] or diffslots['signals'] or
    387                             diffslots['properties']):
    388                             # This is a new thing we need to track.
    389                             new_methods = {'interface':interface['interface'],
    390                                            'methods': diffslots['methods'],
    391                                            'signals': diffslots['signals'],
    392                                            'properties': diffslots['properties']
    393                                            }
    394                             new_object['interfaces'].append(new_methods)
    395                             new_items.append(new_object)
    396                     else:
    397                         # This is a new interface we need to track.
    398                         new_object['interfaces'].append(interface)
    399                         new_items.append(new_object)
    400             else:
    401                 # This is a new object we need to track.
    402                 new_items.append(dbus_object)
    403         return new_items
    404 
    405 
    406     def walk_object(self, bus, object_name, start_path, dbus_objects):
    407         """Walks the given bus and object returns a dictionary representation.
    408            The formate of the dictionary is as follows:
    409            {
    410                Object_name: "string"
    411                interfaces:
    412                [
    413                    interface: "string"
    414                    methods:
    415                    [
    416                        "string1",
    417                        "string2"
    418                    ]
    419                ]
    420            }
    421            Note that the decision to capitalize Object_name is just
    422            a way to force it to appear above the interface-list it
    423            corresponds to, when pretty-printed by the json dumper.
    424            This makes it more logical for humans to read/edit.
    425         Args:
    426             bus: the bus to query, usually system.
    427             object_name: the name of the dbus object to walk.
    428             start_path: the path inside of the object in which to start walking
    429             dbus_objects: current list of dbus objects in the given object
    430         Returns:
    431             A dictionary representation of a dbus object
    432         """
    433         remote_object = bus.get_object(object_name,start_path)
    434         unknown_iface = dbus.Interface(remote_object,
    435                                        'org.freedesktop.DBus.Introspectable')
    436         # Convert the string to an xml DOM object we can walk.
    437         xml = parseString(unknown_iface.Introspect())
    438         for child in xml.childNodes:
    439             if ((child.nodeType == 1) and (child.localName == u'node')):
    440                 interfaces = child.getElementsByTagName('interface')
    441                 for interface in interfaces:
    442                     interface_name = interface.getAttribute('name')
    443                     # First get the methods.
    444                     methods = interface.getElementsByTagName('method')
    445                     method_list = []
    446                     for method in methods:
    447                         method_list.append(method.getAttribute('name'))
    448                     # Repeat the process for signals.
    449                     signals = interface.getElementsByTagName('signal')
    450                     signal_list = []
    451                     for signal in signals:
    452                         signal_list.append(signal.getAttribute('name'))
    453                     # Properties have to be discovered via API call.
    454                     prop_list = []
    455                     try:
    456                         prop_iface = dbus.Interface(remote_object,
    457                             'org.freedesktop.DBus.Properties')
    458                         prop_list = prop_iface.GetAll(interface_name).keys()
    459                     except dbus.exceptions.DBusException:
    460                         # Many daemons do not support this interface,
    461                         # which means they have no properties.
    462                         pass
    463                     # Create the dictionary with all the above.
    464                     dictionary = {'interface':interface_name,
    465                                   'methods':method_list, 'signals':signal_list,
    466                                   'properties':prop_list}
    467                     if dictionary not in dbus_objects:
    468                         dbus_objects.append(dictionary)
    469                 nodes = child.getElementsByTagName('node')
    470                 for node in nodes:
    471                     name = node.getAttribute('name')
    472                     if start_path[-1] != '/':
    473                             start_path = start_path + '/'
    474                     new_name = start_path + name
    475                     self.walk_object(bus, object_name, new_name, dbus_objects)
    476         return {'Object_name':('%s' % object_name), 'interfaces':dbus_objects}
    477 
    478 
    479     def mapper_main(self):
    480         # Currently we only dump the SystemBus. Accessing the SessionBus says:
    481         # "ExecFailed: /usr/bin/dbus-launch terminated abnormally with the
    482         # following error: Autolaunch requested, but X11 support not compiled
    483         # in."
    484         # If this changes at a later date, add dbus.SessionBus() to the dict.
    485         # We've left the code structured to support walking more than one bus
    486         # for such an eventuality.
    487 
    488         buses = {'System Bus': dbus.SystemBus()}
    489 
    490         for busname in buses.keys():
    491             bus = buses[busname]
    492             remote_dbus_object = bus.get_object('org.freedesktop.DBus',
    493                                                 '/org/freedesktop/DBus')
    494             iface = dbus.Interface(remote_dbus_object, 'org.freedesktop.DBus')
    495             dbus_list = []
    496             for i in iface.ListNames():
    497                 # There are some strange listings like ":1" which appear after
    498                 # certain names. Ignore these since we just need the names.
    499                 if i.startswith(':'):
    500                     continue
    501                 dbus_list.append(self.walk_object(bus, i, '/', []))
    502 
    503         # Dump the complete observed dataset to disk. In the somewhat
    504         # typical case, that we will want to rev the baseline to
    505         # match current reality, these files are easily copied and
    506         # checked in as a new baseline.
    507         self.sort_dbus_tree(dbus_list)
    508         observed_data_path = os.path.join(self.outputdir, 'observed')
    509         self.write_dbus_data_to_disk(dbus_list, observed_data_path)
    510 
    511         baseline = self.load_baseline()
    512         test_pass = self.mutual_compare(dbus_list, baseline)
    513 
    514         # Figure out which of the observed API's are callable by specific users
    515         # whose attack surface we are particularly sensitive to:
    516         dbus_cfg = self.load_dbus_config_doms()
    517         for user in self.list_baselined_users():
    518             user_baseline = self.load_baseline(user)
    519             user_observed = []
    520             # user_observed will be a subset of dbus_list. Iterate and check
    521             # against the configured dbus policies as we go:
    522             for objdict in dbus_list:
    523                 for ifacedict in objdict['interfaces']:
    524                     for meth in ifacedict['methods']:
    525                         if (self.check_policies(dbus_cfg,
    526                                                 objdict['Object_name'],
    527                                                 ifacedict['interface'], meth,
    528                                                 user=user)):
    529                             self.add_member(user_observed,
    530                                             objdict['Object_name'],
    531                                             ifacedict['interface'], meth)
    532                     # We don't do permission-checking on signals because
    533                     # signals are allow-all by default. Just copy them over.
    534                     for sig in ifacedict['signals']:
    535                         self.add_signal(user_observed,
    536                                         objdict['Object_name'],
    537                                         ifacedict['interface'], sig)
    538                     # A property might be readable, or even writable, to
    539                     # a given user if they can reach the Get/Set interface
    540                     access = []
    541                     if (self.check_policies(dbus_cfg, objdict['Object_name'],
    542                                             'org.freedesktop.DBus.Properties',
    543                                             'Set', user=user)):
    544                         access.append('Set')
    545                     if (self.check_policies(dbus_cfg, objdict['Object_name'],
    546                                             'org.freedesktop.DBus.Properties',
    547                                             'Get', user=user) or
    548                         self.check_policies(dbus_cfg, objdict['Object_name'],
    549                                             'org.freedesktop.DBus.Properties',
    550                                             'GetAll', user=user)):
    551                         access.append('Get')
    552                     if access:
    553                         access = ','.join(access)
    554                         for prop in ifacedict['properties']:
    555                             self.add_property(user_observed,
    556                                               objdict['Object_name'],
    557                                               ifacedict['interface'],
    558                                               '%s (%s)' % (prop, access))
    559 
    560             self.write_dbus_data_to_disk(user_observed,
    561                                          '%s.%s' % (observed_data_path, user))
    562             test_pass = test_pass and self.mutual_compare(user_observed,
    563                                                           user_baseline, user)
    564         if not test_pass:
    565             raise error.TestFail('Baseline mismatch(es)')
    566 
    567 
    568     def run_once(self):
    569         """
    570         Enumerates all discoverable interfaces, methods, and signals
    571         in dbus-land. Verifies that it matches an expected set.
    572         """
    573         login.wait_for_browser()
    574         self.mapper_main()
    575