1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025
|
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Tests that confidential attributes (or attributes protected by a ACL that
# denies read access) cannot be guessed through wildcard DB searches.
#
# Copyright (C) Catalyst.Net Ltd
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import optparse
import sys
sys.path.insert(0, "bin/python")
import samba
import os
from samba.tests.subunitrun import SubunitOptions, TestProgram
import samba.getopt as options
from ldb import SCOPE_BASE, SCOPE_SUBTREE
from samba.dsdb import SEARCH_FLAG_CONFIDENTIAL, SEARCH_FLAG_PRESERVEONDELETE
from ldb import Message, MessageElement, Dn
from ldb import FLAG_MOD_REPLACE, FLAG_MOD_ADD
from samba.auth import system_session
from samba import gensec, sd_utils
from samba.samdb import SamDB
from samba.credentials import Credentials, DONT_USE_KERBEROS
import samba.tests
from samba.tests import delete_force
import samba.dsdb
parser = optparse.OptionParser("confidential_attr.py [options] <host>")
sambaopts = options.SambaOptions(parser)
parser.add_option_group(sambaopts)
parser.add_option_group(options.VersionOptions(parser))
# use command line creds if available
credopts = options.CredentialsOptions(parser)
parser.add_option_group(credopts)
subunitopts = SubunitOptions(parser)
parser.add_option_group(subunitopts)
opts, args = parser.parse_args()
if len(args) < 1:
parser.print_usage()
sys.exit(1)
host = args[0]
if "://" not in host:
ldaphost = "ldap://%s" % host
else:
ldaphost = host
start = host.rindex("://")
host = host.lstrip(start + 3)
lp = sambaopts.get_loadparm()
creds = credopts.get_credentials(lp)
creds.set_gensec_features(creds.get_gensec_features() | gensec.FEATURE_SEAL)
# When a user does not have access rights to view the objects' attributes,
# Windows and Samba behave slightly differently.
# A windows DC will always act as if the hidden attribute doesn't exist AT ALL
# (for an unprivileged user). So, even for a user that lacks access rights,
# the inverse/'!' queries should return ALL objects. This is similar to the
# kludgeaclredacted behaviour on Samba.
# However, on Samba (for implementation simplicity) we never return a matching
# result for an unprivileged user.
# Either approach is OK, so long as it gets applied consistently and we don't
# disclose any sensitive details by varying what gets returned by the search.
DC_MODE_RETURN_NONE = 0
DC_MODE_RETURN_ALL = 1
#
# Tests start here
#
class ConfidentialAttrCommon(samba.tests.TestCase):
def setUp(self):
super(ConfidentialAttrCommon, self).setUp()
self.ldb_admin = SamDB(ldaphost, credentials=creds,
session_info=system_session(lp), lp=lp)
self.user_pass = "samba123@"
self.base_dn = self.ldb_admin.domain_dn()
self.schema_dn = self.ldb_admin.get_schema_basedn()
self.sd_utils = sd_utils.SDUtils(self.ldb_admin)
# the tests work by setting the 'Confidential' bit in the searchFlags
# for an existing schema attribute. This only works against Windows if
# the systemFlags does not have FLAG_SCHEMA_BASE_OBJECT set for the
# schema attribute being modified. There are only a few attributes that
# meet this criteria (most of which only apply to 'user' objects)
self.conf_attr = "homePostalAddress"
attr_cn = "CN=Address-Home"
# schemaIdGuid for homePostalAddress (used for ACE tests)
self.conf_attr_guid = "16775781-47f3-11d1-a9c3-0000f80367c1"
self.conf_attr_sec_guid = "77b5b886-944a-11d1-aebd-0000f80367c1"
self.attr_dn = "{},{}".format(attr_cn, self.schema_dn)
userou = "OU=conf-attr-test"
self.ou = "{},{}".format(userou, self.base_dn)
self.ldb_admin.create_ou(self.ou)
# use a common username prefix, so we can use sAMAccountName=CATC-* as
# a search filter to only return the users we're interested in
self.user_prefix = "catc-"
# add a test object with this attribute set
self.conf_value = "abcdef"
self.conf_user = "{}conf-user".format(self.user_prefix)
self.ldb_admin.newuser(self.conf_user, self.user_pass, userou=userou)
self.conf_dn = self.get_user_dn(self.conf_user)
self.add_attr(self.conf_dn, self.conf_attr, self.conf_value)
# add a sneaky user that will try to steal our secrets
self.user = "{}sneaky-user".format(self.user_prefix)
self.ldb_admin.newuser(self.user, self.user_pass, userou=userou)
self.ldb_user = self.get_ldb_connection(self.user, self.user_pass)
self.all_users = [self.user, self.conf_user]
# add some other users that also have confidential attributes, so we can
# check we don't disclose their details, particularly in '!' searches
for i in range(1, 3):
username = "{}other-user{}".format(self.user_prefix, i)
self.ldb_admin.newuser(username, self.user_pass, userou=userou)
userdn = self.get_user_dn(username)
self.add_attr(userdn, self.conf_attr, "xyz{}".format(i))
self.all_users.append(username)
# there are 4 users in the OU, plus the OU itself
self.test_dn = self.ou
self.total_objects = len(self.all_users) + 1
self.objects_with_attr = 3
# sanity-check the flag is not already set (this'll cause problems if
# previous test run didn't clean up properly)
search_flags = self.get_attr_search_flags(self.attr_dn)
self.assertTrue(int(search_flags) & SEARCH_FLAG_CONFIDENTIAL == 0,
"{} searchFlags already {}".format(self.conf_attr,
search_flags))
def tearDown(self):
super(ConfidentialAttrCommon, self).tearDown()
self.ldb_admin.delete(self.ou, ["tree_delete:1"])
def add_attr(self, dn, attr, value):
m = Message()
m.dn = Dn(self.ldb_admin, dn)
m[attr] = MessageElement(value, FLAG_MOD_ADD, attr)
self.ldb_admin.modify(m)
def set_schema_update_now(self):
ldif = """
dn:
changetype: modify
add: schemaUpdateNow
schemaUpdateNow: 1
"""
self.ldb_admin.modify_ldif(ldif)
def set_attr_search_flags(self, attr_dn, flags):
"""Modifies the searchFlags for an object in the schema"""
m = Message()
m.dn = Dn(self.ldb_admin, attr_dn)
m['searchFlags'] = MessageElement(flags, FLAG_MOD_REPLACE,
'searchFlags')
self.ldb_admin.modify(m)
# note we have to update the schema for this change to take effect (on
# Windows, at least)
self.set_schema_update_now()
def get_attr_search_flags(self, attr_dn):
"""Marks the attribute under test as being confidential"""
res = self.ldb_admin.search(attr_dn, scope=SCOPE_BASE,
attrs=['searchFlags'])
return res[0]['searchFlags'][0]
def make_attr_confidential(self):
"""Marks the attribute under test as being confidential"""
# work out the original 'searchFlags' value before we overwrite it
old_value = self.get_attr_search_flags(self.attr_dn)
self.set_attr_search_flags(self.attr_dn, str(SEARCH_FLAG_CONFIDENTIAL))
# reset the value after the test completes
self.addCleanup(self.set_attr_search_flags, self.attr_dn, old_value)
# The behaviour of the DC can differ in some cases, depending on whether
# we're talking to a Windows DC or a Samba DC
def guess_dc_mode(self):
# if we're in selftest, we can be pretty sure it's a Samba DC
if os.environ.get('SAMBA_SELFTEST') == '1':
return DC_MODE_RETURN_NONE
searches = self.get_negative_match_all_searches()
res = self.ldb_user.search(self.test_dn, expression=searches[0],
scope=SCOPE_SUBTREE)
# we default to DC_MODE_RETURN_NONE (samba).Update this if it
# looks like we're talking to a Windows DC
if len(res) == self.total_objects:
return DC_MODE_RETURN_ALL
# otherwise assume samba DC behaviour
return DC_MODE_RETURN_NONE
def get_user_dn(self, name):
return "CN={},{}".format(name, self.ou)
def get_user_sid_string(self, username):
user_dn = self.get_user_dn(username)
user_sid = self.sd_utils.get_object_sid(user_dn)
return str(user_sid)
def get_ldb_connection(self, target_username, target_password):
creds_tmp = Credentials()
creds_tmp.set_username(target_username)
creds_tmp.set_password(target_password)
creds_tmp.set_domain(creds.get_domain())
creds_tmp.set_realm(creds.get_realm())
creds_tmp.set_workstation(creds.get_workstation())
features = creds_tmp.get_gensec_features() | gensec.FEATURE_SEAL
creds_tmp.set_gensec_features(features)
creds_tmp.set_kerberos_state(DONT_USE_KERBEROS)
ldb_target = SamDB(url=ldaphost, credentials=creds_tmp, lp=lp)
return ldb_target
def assert_not_in_result(self, res, exclude_dn):
for msg in res:
self.assertNotEqual(msg.dn, exclude_dn,
"Search revealed object {}".format(exclude_dn))
def assert_search_result(self, expected_num, expr, samdb):
# try asking for different attributes back: None/all, the confidential
# attribute itself, and a random unrelated attribute
attr_filters = [None, ["*"], [self.conf_attr], ['name']]
for attr in attr_filters:
res = samdb.search(self.test_dn, expression=expr,
scope=SCOPE_SUBTREE, attrs=attr)
self.assertTrue(len(res) == expected_num,
"%u results, not %u for search %s, attr %s" %
(len(res), expected_num, expr, str(attr)))
# return a selection of searches that match exactly against the test object
def get_exact_match_searches(self):
first_char = self.conf_value[:1]
last_char = self.conf_value[-1:]
test_attr = self.conf_attr
searches = [
# search for the attribute using a sub-string wildcard
# (which could reveal the attribute's actual value)
"({}={}*)".format(test_attr, first_char),
"({}=*{})".format(test_attr, last_char),
# sanity-check equality against an exact match on value
"({}={})".format(test_attr, self.conf_value),
# '~=' searches don't work against Samba
# sanity-check an approx search against an exact match on value
# "({}~={})".format(test_attr, self.conf_value),
# check wildcard in an AND search...
"(&({}={}*)(objectclass=*))".format(test_attr, first_char),
# ...an OR search (against another term that will never match)
"(|({}={}*)(objectclass=banana))".format(test_attr, first_char)]
return searches
# return searches that match any object with the attribute under test
def get_match_all_searches(self):
searches = [
# check a full wildcard against the confidential attribute
# (which could reveal the attribute's presence/absence)
"({}=*)".format(self.conf_attr),
# check wildcard in an AND search...
"(&(objectclass=*)({}=*))".format(self.conf_attr),
# ...an OR search (against another term that will never match)
"(|(objectclass=banana)({}=*))".format(self.conf_attr),
# check <=, and >= expressions that would normally find a match
"({}>=0)".format(self.conf_attr),
"({}<=ZZZZZZZZZZZ)".format(self.conf_attr)]
return searches
def assert_conf_attr_searches(self, has_rights_to=0, samdb=None):
"""Check searches against the attribute under test work as expected"""
if samdb is None:
samdb = self.ldb_user
if has_rights_to == "all":
has_rights_to = self.objects_with_attr
# these first few searches we just expect to match against the one
# object under test that we're trying to guess the value of
expected_num = 1 if has_rights_to > 0 else 0
for search in self.get_exact_match_searches():
self.assert_search_result(expected_num, search, samdb)
# these next searches will match any objects we have rights to see
expected_num = has_rights_to
for search in self.get_match_all_searches():
self.assert_search_result(expected_num, search, samdb)
# The following are double negative searches (i.e. NOT non-matching-
# condition) which will therefore match ALL objects, including the test
# object(s).
def get_negative_match_all_searches(self):
first_char = self.conf_value[:1]
last_char = self.conf_value[-1:]
not_first_char = chr(ord(first_char) + 1)
not_last_char = chr(ord(last_char) + 1)
searches = [
"(!({}={}*))".format(self.conf_attr, not_first_char),
"(!({}=*{}))".format(self.conf_attr, not_last_char)]
return searches
# the following searches will not match against the test object(s). So
# a user with sufficient rights will see an inverse sub-set of objects.
# (An unprivileged user would either see all objects on Windows, or no
# objects on Samba)
def get_inverse_match_searches(self):
first_char = self.conf_value[:1]
last_char = self.conf_value[-1:]
searches = [
"(!({}={}*))".format(self.conf_attr, first_char),
"(!({}=*{}))".format(self.conf_attr, last_char)]
return searches
def negative_searches_all_rights(self, total_objects=None):
expected_results = {}
if total_objects is None:
total_objects = self.total_objects
# these searches should match ALL objects (including the OU)
for search in self.get_negative_match_all_searches():
expected_results[search] = total_objects
# a ! wildcard should only match the objects without the attribute
search = "(!({}=*))".format(self.conf_attr)
expected_results[search] = total_objects - self.objects_with_attr
# whereas the inverse searches should match all objects *except* the
# one under test
for search in self.get_inverse_match_searches():
expected_results[search] = total_objects - 1
return expected_results
# Returns the expected negative (i.e. '!') search behaviour when talking to
# a DC with DC_MODE_RETURN_ALL behaviour, i.e. we assert that users
# without rights always see ALL objects in '!' searches
def negative_searches_return_all(self, has_rights_to=0,
total_objects=None):
"""Asserts user without rights cannot see objects in '!' searches"""
expected_results = {}
if total_objects is None:
total_objects = self.total_objects
# Windows 'hides' objects by always returning all of them, so negative
# searches that match all objects will simply return all objects
for search in self.get_negative_match_all_searches():
expected_results[search] = total_objects
# if the search is matching on an inverse subset (everything except the
# object under test), the
inverse_searches = self.get_inverse_match_searches()
inverse_searches += ["(!({}=*))".format(self.conf_attr)]
for search in inverse_searches:
expected_results[search] = total_objects - has_rights_to
return expected_results
# Returns the expected negative (i.e. '!') search behaviour when talking to
# a DC with DC_MODE_RETURN_NONE behaviour, i.e. we assert that users
# without rights cannot see objects in '!' searches at all
def negative_searches_return_none(self, has_rights_to=0):
expected_results = {}
# the 'match-all' searches should only return the objects we have
# access rights to (if any)
for search in self.get_negative_match_all_searches():
expected_results[search] = has_rights_to
# for inverse matches, we should NOT be told about any objects at all
inverse_searches = self.get_inverse_match_searches()
inverse_searches += ["(!({}=*))".format(self.conf_attr)]
for search in inverse_searches:
expected_results[search] = 0
return expected_results
# Returns the expected negative (i.e. '!') search behaviour. This varies
# depending on what type of DC we're talking to (i.e. Windows or Samba)
# and what access rights the user has
def negative_search_expected_results(self, has_rights_to, dc_mode,
total_objects=None):
if has_rights_to == "all":
expect_results = self.negative_searches_all_rights(total_objects)
# if it's a Samba DC, we only expect the 'match-all' searches to return
# the objects that we have access rights to (all others are hidden).
# Whereas Windows 'hides' the objects by always returning all of them
elif dc_mode == DC_MODE_RETURN_NONE:
expect_results = self.negative_searches_return_none(has_rights_to)
else:
expect_results = self.negative_searches_return_all(has_rights_to,
total_objects)
return expect_results
def assert_negative_searches(self, has_rights_to=0,
dc_mode=DC_MODE_RETURN_NONE, samdb=None):
"""Asserts user without rights cannot see objects in '!' searches"""
if samdb is None:
samdb = self.ldb_user
# build a dictionary of key=search-expr, value=expected_num assertions
expected_results = self.negative_search_expected_results(has_rights_to,
dc_mode)
for search, expected_num in expected_results.items():
self.assert_search_result(expected_num, search, samdb)
def assert_attr_returned(self, expect_attr, samdb, attrs):
# does a query that should always return a successful result, and
# checks whether the confidential attribute is present
res = samdb.search(self.conf_dn, expression="(objectClass=*)",
scope=SCOPE_SUBTREE, attrs=attrs)
self.assertTrue(len(res) == 1)
attr_returned = False
for msg in res:
if self.conf_attr in msg:
attr_returned = True
self.assertEqual(expect_attr, attr_returned)
def assert_attr_visible(self, expect_attr, samdb=None):
if samdb is None:
samdb = self.ldb_user
# sanity-check confidential attribute is/isn't returned as expected
# based on the filter attributes we ask for
self.assert_attr_returned(expect_attr, samdb, attrs=None)
self.assert_attr_returned(expect_attr, samdb, attrs=["*"])
self.assert_attr_returned(expect_attr, samdb, attrs=[self.conf_attr])
# filtering on a different attribute should never return the conf_attr
self.assert_attr_returned(expect_attr=False, samdb=samdb,
attrs=['name'])
def assert_attr_visible_to_admin(self):
# sanity-check the admin user can always see the confidential attribute
self.assert_conf_attr_searches(has_rights_to="all", samdb=self.ldb_admin)
self.assert_negative_searches(has_rights_to="all", samdb=self.ldb_admin)
self.assert_attr_visible(expect_attr=True, samdb=self.ldb_admin)
class ConfidentialAttrTest(ConfidentialAttrCommon):
def test_basic_search(self):
"""Basic test confidential attributes aren't disclosed via searches"""
# check we can see a non-confidential attribute in a basic searches
self.assert_conf_attr_searches(has_rights_to="all")
self.assert_negative_searches(has_rights_to="all")
self.assert_attr_visible(expect_attr=True)
# now make the attribute confidential. Repeat the tests and check that
# an ordinary user can't see the attribute, or indirectly match on the
# attribute via the search expression
self.make_attr_confidential()
self.assert_conf_attr_searches(has_rights_to=0)
dc_mode = self.guess_dc_mode()
self.assert_negative_searches(has_rights_to=0, dc_mode=dc_mode)
self.assert_attr_visible(expect_attr=False)
# sanity-check we haven't hidden the attribute from the admin as well
self.assert_attr_visible_to_admin()
def _test_search_with_allow_acl(self, allow_ace):
"""Checks a ACE with 'CR' rights can override a confidential attr"""
# make the test attribute confidential and check user can't see it
self.make_attr_confidential()
self.assert_conf_attr_searches(has_rights_to=0)
dc_mode = self.guess_dc_mode()
self.assert_negative_searches(has_rights_to=0, dc_mode=dc_mode)
self.assert_attr_visible(expect_attr=False)
# apply the allow ACE to the object under test
self.sd_utils.dacl_add_ace(self.conf_dn, allow_ace)
# the user should now be able to see the attribute for the one object
# we gave it rights to
self.assert_conf_attr_searches(has_rights_to=1)
self.assert_negative_searches(has_rights_to=1, dc_mode=dc_mode)
self.assert_attr_visible(expect_attr=True)
# sanity-check the admin can still see the attribute
self.assert_attr_visible_to_admin()
def test_search_with_attr_acl_override(self):
"""Make the confidential attr visible via an OA attr ACE"""
# set the SEC_ADS_CONTROL_ACCESS bit ('CR') for the user for the
# attribute under test, so the user can see it once more
user_sid = self.get_user_sid_string(self.user)
ace = "(OA;;CR;{};;{})".format(self.conf_attr_guid, user_sid)
self._test_search_with_allow_acl(ace)
def test_search_with_propset_acl_override(self):
"""Make the confidential attr visible via a Property-set ACE"""
# set the SEC_ADS_CONTROL_ACCESS bit ('CR') for the user for the
# property-set containing the attribute under test (i.e. the
# attributeSecurityGuid), so the user can see it once more
user_sid = self.get_user_sid_string(self.user)
ace = "(OA;;CR;{};;{})".format(self.conf_attr_sec_guid, user_sid)
self._test_search_with_allow_acl(ace)
def test_search_with_acl_override(self):
"""Make the confidential attr visible via a general 'allow' ACE"""
# set the allow SEC_ADS_CONTROL_ACCESS bit ('CR') for the user
user_sid = self.get_user_sid_string(self.user)
ace = "(A;;CR;;;{})".format(user_sid)
self._test_search_with_allow_acl(ace)
def test_search_with_blanket_oa_acl(self):
"""Make the confidential attr visible via a non-specific OA ACE"""
# this just checks that an Object Access (OA) ACE without a GUID
# specified will work the same as an 'Access' (A) ACE
user_sid = self.get_user_sid_string(self.user)
ace = "(OA;;CR;;;{})".format(user_sid)
self._test_search_with_allow_acl(ace)
def _test_search_with_neutral_acl(self, neutral_ace):
"""Checks that a user does NOT gain access via an unrelated ACE"""
# make the test attribute confidential and check user can't see it
self.make_attr_confidential()
self.assert_conf_attr_searches(has_rights_to=0)
dc_mode = self.guess_dc_mode()
self.assert_negative_searches(has_rights_to=0, dc_mode=dc_mode)
self.assert_attr_visible(expect_attr=False)
# apply the ACE to the object under test
self.sd_utils.dacl_add_ace(self.conf_dn, neutral_ace)
# this should make no difference to the user's ability to see the attr
self.assert_conf_attr_searches(has_rights_to=0)
self.assert_negative_searches(has_rights_to=0, dc_mode=dc_mode)
self.assert_attr_visible(expect_attr=False)
# sanity-check the admin can still see the attribute
self.assert_attr_visible_to_admin()
def test_search_with_neutral_acl(self):
"""Give the user all rights *except* CR for any attributes"""
# give the user all rights *except* CR and check it makes no difference
user_sid = self.get_user_sid_string(self.user)
ace = "(A;;RPWPCCDCLCLORCWOWDSDDTSW;;;{})".format(user_sid)
self._test_search_with_neutral_acl(ace)
def test_search_with_neutral_attr_acl(self):
"""Give the user all rights *except* CR for the attribute under test"""
# giving user all OA rights *except* CR should make no difference
user_sid = self.get_user_sid_string(self.user)
rights = "RPWPCCDCLCLORCWOWDSDDTSW"
ace = "(OA;;{};{};;{})".format(rights, self.conf_attr_guid, user_sid)
self._test_search_with_neutral_acl(ace)
def test_search_with_neutral_cr_acl(self):
"""Give the user CR rights for *another* unrelated attribute"""
# giving user object-access CR rights to an unrelated attribute
user_sid = self.get_user_sid_string(self.user)
# use the GUID for sAMAccountName here (for no particular reason)
unrelated_attr = "3e0abfd0-126a-11d0-a060-00aa006c33ed"
ace = "(OA;;CR;{};;{})".format(unrelated_attr, user_sid)
self._test_search_with_neutral_acl(ace)
# Check that a Deny ACL on an attribute doesn't reveal confidential info
class ConfidentialAttrTestDenyAcl(ConfidentialAttrCommon):
def assert_not_in_result(self, res, exclude_dn):
for msg in res:
self.assertNotEqual(msg.dn, exclude_dn,
"Search revealed object {}".format(exclude_dn))
# deny ACL tests are slightly different as we are only denying access to
# the one object under test (rather than any objects with that attribute).
# Therefore we need an extra check that we don't reveal the test object
# in the search, if we're not supposed to
def assert_search_result(self, expected_num, expr, samdb,
excl_testobj=False):
# try asking for different attributes back: None/all, the confidential
# attribute itself, and a random unrelated attribute
attr_filters = [None, ["*"], [self.conf_attr], ['name']]
for attr in attr_filters:
res = samdb.search(self.test_dn, expression=expr,
scope=SCOPE_SUBTREE, attrs=attr)
self.assertTrue(len(res) == expected_num,
"%u results, not %u for search %s, attr %s" %
(len(res), expected_num, expr, str(attr)))
# assert we haven't revealed the hidden test-object
if excl_testobj:
self.assert_not_in_result(res, exclude_dn=self.conf_dn)
# we make a few tweaks to the regular version of this function to cater to
# denying specifically one object via an ACE
def assert_conf_attr_searches(self, has_rights_to=0, samdb=None):
"""Check searches against the attribute under test work as expected"""
if samdb is None:
samdb = self.ldb_user
# make sure the test object is not returned if we've been denied rights
# to it via an ACE
excl_testobj = True if has_rights_to == "deny-one" else False
# these first few searches we just expect to match against the one
# object under test that we're trying to guess the value of
expected_num = 1 if has_rights_to == "all" else 0
for search in self.get_exact_match_searches():
self.assert_search_result(expected_num, search, samdb,
excl_testobj)
# these next searches will match any objects with the attribute that
# we have rights to see (i.e. all except the object under test)
if has_rights_to == "all":
expected_num = self.objects_with_attr
elif has_rights_to == "deny-one":
expected_num = self.objects_with_attr - 1
for search in self.get_match_all_searches():
self.assert_search_result(expected_num, search, samdb,
excl_testobj)
def negative_searches_return_none(self, has_rights_to=0):
expected_results = {}
# on Samba we will see the objects we have rights to, but the one we
# are denied access to will be hidden
searches = self.get_negative_match_all_searches()
searches += self.get_inverse_match_searches()
for search in searches:
expected_results[search] = self.total_objects - 1
# The wildcard returns the objects without this attribute as normal.
search = "(!({}=*))".format(self.conf_attr)
expected_results[search] = self.total_objects - self.objects_with_attr
return expected_results
def negative_searches_return_all(self, has_rights_to=0,
total_objects=None):
expected_results = {}
# When a user lacks access rights to an object, Windows 'hides' it in
# '!' searches by always returning it, regardless of whether it matches
searches = self.get_negative_match_all_searches()
searches += self.get_inverse_match_searches()
for search in searches:
expected_results[search] = self.total_objects
# in the wildcard case, the one object we don't have rights to gets
# bundled in with the objects that don't have the attribute at all
search = "(!({}=*))".format(self.conf_attr)
has_rights_to = self.objects_with_attr - 1
expected_results[search] = self.total_objects - has_rights_to
return expected_results
def assert_negative_searches(self, has_rights_to=0,
dc_mode=DC_MODE_RETURN_NONE, samdb=None):
"""Asserts user without rights cannot see objects in '!' searches"""
if samdb is None:
samdb = self.ldb_user
# As the deny ACL is only denying access to one particular object, add
# an extra check that the denied object is not returned. (We can only
# assert this if the '!'/negative search behaviour is to suppress any
# objects we don't have access rights to)
excl_testobj = False
if has_rights_to != "all" and dc_mode == DC_MODE_RETURN_NONE:
excl_testobj = True
# build a dictionary of key=search-expr, value=expected_num assertions
expected_results = self.negative_search_expected_results(has_rights_to,
dc_mode)
for search, expected_num in expected_results.items():
self.assert_search_result(expected_num, search, samdb,
excl_testobj=excl_testobj)
def _test_search_with_deny_acl(self, ace):
# check the user can see the attribute initially
self.assert_conf_attr_searches(has_rights_to="all")
self.assert_negative_searches(has_rights_to="all")
self.assert_attr_visible(expect_attr=True)
# add the ACE that denies access to the attr under test
self.sd_utils.dacl_add_ace(self.conf_dn, ace)
# the user shouldn't be able to see the attribute anymore
self.assert_conf_attr_searches(has_rights_to="deny-one")
dc_mode = self.guess_dc_mode()
self.assert_negative_searches(has_rights_to="deny-one",
dc_mode=dc_mode)
self.assert_attr_visible(expect_attr=False)
# sanity-check we haven't hidden the attribute from the admin as well
self.assert_attr_visible_to_admin()
def test_search_with_deny_attr_acl(self):
"""Checks a deny ACE works the same way as a confidential attribute"""
# add an ACE that denies the user Read Property (RP) access to the attr
# (which is similar to making the attribute confidential)
user_sid = self.get_user_sid_string(self.user)
ace = "(OD;;RP;{};;{})".format(self.conf_attr_guid, user_sid)
# check the user cannot see the attribute anymore
self._test_search_with_deny_acl(ace)
def test_search_with_deny_acl(self):
"""Checks a blanket deny ACE denies access to an object's attributes"""
# add an blanket deny ACE for Read Property (RP) rights
user_dn = self.get_user_dn(self.user)
user_sid = self.sd_utils.get_object_sid(user_dn)
ace = "(D;;RP;;;{})".format(str(user_sid))
# check the user cannot see the attribute anymore
self._test_search_with_deny_acl(ace)
def test_search_with_deny_propset_acl(self):
"""Checks a deny ACE on the attribute's Property-Set"""
# add an blanket deny ACE for Read Property (RP) rights
user_sid = self.get_user_sid_string(self.user)
ace = "(OD;;RP;{};;{})".format(self.conf_attr_sec_guid, user_sid)
# check the user cannot see the attribute anymore
self._test_search_with_deny_acl(ace)
def test_search_with_blanket_oa_deny_acl(self):
"""Checks a non-specific 'OD' ACE works the same as a 'D' ACE"""
# this just checks that adding a 'Object Deny' (OD) ACE without
# specifying a GUID will work the same way as a 'Deny' (D) ACE
user_sid = self.get_user_sid_string(self.user)
ace = "(OD;;RP;;;{})".format(user_sid)
# check the user cannot see the attribute anymore
self._test_search_with_deny_acl(ace)
# Check that using the dirsync controls doesn't reveal confidential attributes
class ConfidentialAttrTestDirsync(ConfidentialAttrCommon):
def setUp(self):
super(ConfidentialAttrTestDirsync, self).setUp()
self.dirsync = ["dirsync:1:1:1000"]
# because we need to search on the base DN when using the dirsync
# controls, we need an extra filter for the inverse ('!') search,
# so we don't get thousands of objects returned
self.extra_filter = \
"(&(samaccountname={}*)(!(isDeleted=*)))".format(self.user_prefix)
self.single_obj_filter = \
"(&(samaccountname={})(!(isDeleted=*)))".format(self.conf_user)
self.attr_filters = [None, ["*"], ["name"]]
# Note dirsync behaviour is slighty different for the attribute under
# test - when you have full access rights, it only returns the objects
# that actually have this attribute (i.e. it doesn't return an empty
# message with just the DN). So we add the 'name' attribute into the
# attribute filter to avoid complicating our assertions further
self.attr_filters += [[self.conf_attr, "name"]]
def assert_search_result(self, expected_num, expr, samdb, base_dn=None):
# Note dirsync must always search on the partition base DN
if base_dn is None:
base_dn = self.base_dn
# we need an extra filter for dirsync because:
# - we search on the base DN, so otherwise the '!' searches return
# thousands of unrelated results, and
# - we make the test attribute preserve-on-delete in one case, so we
# want to weed out results from any previous test runs
search = "(&{}{})".format(expr, self.extra_filter)
for attr in self.attr_filters:
res = samdb.search(base_dn, expression=search, scope=SCOPE_SUBTREE,
attrs=attr, controls=self.dirsync)
self.assertTrue(len(res) == expected_num,
"%u results, not %u for search %s, attr %s" %
(len(res), expected_num, search, str(attr)))
def assert_attr_returned(self, expect_attr, samdb, attrs,
no_result_ok=False):
# When using dirsync, the base DN we search on needs to be a naming
# context. Add an extra filter to ignore all the objects we aren't
# interested in
expr = self.single_obj_filter
res = samdb.search(self.base_dn, expression=expr, scope=SCOPE_SUBTREE,
attrs=attrs, controls=self.dirsync)
self.assertTrue(len(res) == 1 or no_result_ok)
attr_returned = False
for msg in res:
if self.conf_attr in msg and len(msg[self.conf_attr]) > 0:
attr_returned = True
self.assertEqual(expect_attr, attr_returned)
def assert_attr_visible(self, expect_attr, samdb=None):
if samdb is None:
samdb = self.ldb_user
# sanity-check confidential attribute is/isn't returned as expected
# based on the filter attributes we ask for
self.assert_attr_returned(expect_attr, samdb, attrs=None)
self.assert_attr_returned(expect_attr, samdb, attrs=["*"])
if expect_attr:
self.assert_attr_returned(expect_attr, samdb,
attrs=[self.conf_attr])
else:
# The behaviour with dirsync when asking solely for an attribute
# that you don't have rights to is a bit strange. Samba returns
# no result rather than an empty message with just the DN.
# Presumably this is due to dirsync module behaviour. It's not
# disclosive in that the DC behaves the same way as if you asked
# for a garbage/non-existent attribute
self.assert_attr_returned(expect_attr, samdb,
attrs=[self.conf_attr],
no_result_ok=True)
self.assert_attr_returned(expect_attr, samdb,
attrs=["garbage"], no_result_ok=True)
# filtering on a different attribute should never return the conf_attr
self.assert_attr_returned(expect_attr=False, samdb=samdb,
attrs=['name'])
def assert_negative_searches(self, has_rights_to=0,
dc_mode=DC_MODE_RETURN_NONE, samdb=None):
"""Asserts user without rights cannot see objects in '!' searches"""
if samdb is None:
samdb = self.ldb_user
# because dirsync uses an extra filter, the total objects we expect
# here only includes the user objects (not the parent OU)
total_objects = len(self.all_users)
expected_results = self.negative_search_expected_results(has_rights_to,
dc_mode,
total_objects)
for search, expected_num in expected_results.items():
self.assert_search_result(expected_num, search, samdb)
def test_search_with_dirsync(self):
"""Checks dirsync controls don't reveal confidential attributes"""
self.assert_conf_attr_searches(has_rights_to="all")
self.assert_attr_visible(expect_attr=True)
self.assert_negative_searches(has_rights_to="all")
# make the test attribute confidential and check user can't see it,
# even if they use the dirsync controls
self.make_attr_confidential()
self.assert_conf_attr_searches(has_rights_to=0)
self.assert_attr_visible(expect_attr=False)
dc_mode = self.guess_dc_mode()
self.assert_negative_searches(has_rights_to=0, dc_mode=dc_mode)
# as a final sanity-check, make sure the admin can still see the attr
self.assert_conf_attr_searches(has_rights_to="all",
samdb=self.ldb_admin)
self.assert_attr_visible(expect_attr=True, samdb=self.ldb_admin)
self.assert_negative_searches(has_rights_to="all",
samdb=self.ldb_admin)
def get_guid(self, dn):
"""Returns an object's GUID (in string format)"""
res = self.ldb_admin.search(base=dn, attrs=["objectGUID"],
scope=SCOPE_BASE)
guid = res[0]['objectGUID'][0]
return self.ldb_admin.schema_format_value("objectGUID", guid)
def make_attr_preserve_on_delete(self):
"""Marks the attribute under test as being preserve on delete"""
# work out the original 'searchFlags' value before we overwrite it
search_flags = int(self.get_attr_search_flags(self.attr_dn))
# check we've already set the confidential flag
self.assertTrue(search_flags & SEARCH_FLAG_CONFIDENTIAL != 0)
search_flags |= SEARCH_FLAG_PRESERVEONDELETE
self.set_attr_search_flags(self.attr_dn, str(search_flags))
def change_attr_under_test(self, attr_name, attr_cn):
# change the attribute that the test code uses
self.conf_attr = attr_name
self.attr_dn = "{},{}".format(attr_cn, self.schema_dn)
# set the new attribute for the user-under-test
self.add_attr(self.conf_dn, self.conf_attr, self.conf_value)
# 2 other users also have the attribute-under-test set (to a randomish
# value). Set the new attribute for them now (normally this gets done
# in the setUp())
for username in self.all_users:
if "other-user" in username:
dn = self.get_user_dn(username)
self.add_attr(dn, self.conf_attr, "xyz-blah")
def test_search_with_dirsync_deleted_objects(self):
"""Checks dirsync doesn't reveal confidential info for deleted objs"""
# change the attribute we're testing (we'll preserve on delete for this
# test case, which means the attribute-under-test hangs around after
# the test case finishes, and would interfere with the searches for
# subsequent other test cases)
self.change_attr_under_test("carLicense", "CN=carLicense")
# Windows dirsync behaviour is a little strange when you request
# attributes that deleted objects no longer have, so just request 'all
# attributes' to simplify the test logic
self.attr_filters = [None, ["*"]]
# normally dirsync uses extra filters to exclude deleted objects that
# we're not interested in. Override these filters so they WILL include
# deleted objects, but only from this particular test run. We can do
# this by matching lastKnownParent against this test case's OU, which
# will match any deleted child objects.
ou_guid = self.get_guid(self.ou)
deleted_filter = "(lastKnownParent=<GUID={}>)".format(ou_guid)
# the extra-filter will get combined via AND with the search expression
# we're testing, i.e. filter on the confidential attribute AND only
# include non-deleted objects, OR deleted objects from this test run
exclude_deleted_objs_filter = self.extra_filter
self.extra_filter = "(|{}{})".format(exclude_deleted_objs_filter,
deleted_filter)
# for matching on a single object, the search expresseion becomes:
# match exactly by account-name AND either a non-deleted object OR a
# deleted object from this test run
match_by_name = "(samaccountname={})".format(self.conf_user)
not_deleted = "(!(isDeleted=*))"
self.single_obj_filter = "(&{}(|{}{}))".format(match_by_name,
not_deleted,
deleted_filter)
# check that the search filters work as expected
self.assert_conf_attr_searches(has_rights_to="all")
self.assert_attr_visible(expect_attr=True)
self.assert_negative_searches(has_rights_to="all")
# make the test attribute confidential *and* preserve on delete.
self.make_attr_confidential()
self.make_attr_preserve_on_delete()
# check we can't see the objects now, even with using dirsync controls
self.assert_conf_attr_searches(has_rights_to=0)
self.assert_attr_visible(expect_attr=False)
dc_mode = self.guess_dc_mode()
self.assert_negative_searches(has_rights_to=0, dc_mode=dc_mode)
# now delete the users (except for the user whose LDB connection
# we're currently using)
for user in self.all_users:
if user != self.user:
self.ldb_admin.delete(self.get_user_dn(user))
# check we still can't see the objects
self.assert_conf_attr_searches(has_rights_to=0)
self.assert_negative_searches(has_rights_to=0, dc_mode=dc_mode)
TestProgram(module=__name__, opts=subunitopts)
|