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 5import dbus 6import json 7import logging 8import os.path 9from xml.dom.minidom import parse, parseString 10 11from autotest_lib.client.bin import test, utils 12from autotest_lib.client.common_lib import error 13from autotest_lib.client.cros import constants, login 14 15class 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