Files
Gaffner d029698149 smbclient ACL Support (#1240)
* Added smbcacls

* Changed example script

* improve ACL manage

* improve acl manage

* improved acl

* Closing all handles

* fixed directory and handle is open bug in acl.py

* Changed help a little bit

* Ignore from empty ACL

* fix indentation

* removee example to different PR

---------

Co-authored-by: john57 <your_email_address@example.com>
2026-02-18 00:42:34 -03:00

366 lines
18 KiB
Python

# Impacket - Collection of Python classes for working with network protocols.
#
# Copyright Fortra, LLC and its affiliated companies
#
# All rights reserved.
#
# This software is provided under a slightly modified version
# of the Apache Software License. See the accompanying LICENSE file
# for more information.
#
# Description:
# Tests for the acl module (Windows ACL management over SMB)
#
# Author:
# Gefen Altshuler (@gaffner)
#
from __future__ import print_function
import unittest
from unittest import mock
from unittest.mock import MagicMock, patch
from impacket.acl import (
SMBFileACL, FileNTACE, ACL_SID, FileNTUser, SecurityAttributes,
SUPPORTED_PERMISSIONS
)
from impacket.smb3structs import FileSecInformation
class TestSMBFileACL(unittest.TestCase):
"""Test suite for SMBFileACL class"""
@staticmethod
def get_mock_smb_file_acl():
"""
Create a mocked SMBFileACL instance with patched SMB connection
"""
with patch('impacket.acl.SMBConnection'), \
patch('impacket.acl.SMBTransport'):
acl_manager = SMBFileACL(
ip='192.168.1.100',
remote_name='TestServer',
username='testuser',
password='testpass',
domain='TESTDOM'
)
# Mock the policy handle
acl_manager.policy_handle = MagicMock()
return acl_manager
@patch('impacket.acl.lsat.hLsarLookupNames3')
def test_name_to_sid(self, mock_lookup):
"""Test converting username to SID"""
# Mock the LSA response
mock_sid = MagicMock()
mock_sid.getData.return_value = b'\x00\x00\x00\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
mock_lookup.return_value = {
'TranslatedSids': {
'Sids': [{'Sid': mock_sid}]
}
}
acl_manager = self.get_mock_smb_file_acl()
sid_bytes = acl_manager.name_to_sid('Administrator')
# Verify the SID bytes (skip first 4 bytes which are the count attribute)
self.assertEqual(len(sid_bytes), 28)
mock_lookup.assert_called_once()
def test_permissions_to_ace_grant(self):
"""Test converting permissions string to ACE for grant action"""
acl_manager = self.get_mock_smb_file_acl()
# Mock name_to_sid
test_sid = b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
with patch.object(acl_manager, 'name_to_sid', return_value=test_sid):
ace = acl_manager.permissions_to_ace('Administrator', 'R,W', 'grant')
self.assertIsInstance(ace, FileNTACE)
self.assertEqual(ace.action, 'grant')
# Check that permissions were OR'd together
expected_rights = SUPPORTED_PERMISSIONS['R'] | SUPPORTED_PERMISSIONS['W']
ace_rights = (ace['SpecificRights'] |
(ace['StandardRights'] << 16) |
(ace['GenericRights'] << 24))
self.assertGreater(ace_rights, 0)
def test_permissions_to_ace_invalid_permission(self):
"""Test handling of invalid permissions"""
acl_manager = self.get_mock_smb_file_acl()
test_sid = b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
with patch.object(acl_manager, 'name_to_sid', return_value=test_sid):
# Should raise exception when no valid permissions
with self.assertRaises(Exception) as context:
acl_manager.permissions_to_ace('Administrator', 'Z,Y,Q', 'grant')
self.assertIn("No valid permissions", str(context.exception))
def test_insert_permission_grant_new_ace(self):
"""Test granting permissions by adding a new ACE"""
# Original security descriptor with one ACE
sec_blob = (
b'\x01\x00\x04\x80\x14\x00\x00\x000\x00\x00\x00\x00\x00\x00\x00L\x00\x00\x00'
b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xe9\x03\x00\x00'
b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\x01\x02\x00\x00'
b'\x02\x00l\x00\x04\x00\x00\x00'
b'\x00\x10$\x00\xff\x01\x1f\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xe9\x03\x00\x00'
b'\x00\x10\x14\x00\xff\x01\x1f\x00\x01\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00'
b'\x00\x10\x18\x00\xff\x01\x1f\x00\x01\x02\x00\x00\x00\x00\x00\x05 \x00\x00\x00 \x02\x00\x00'
b'\x00\x10\x14\x00\xff\x01\x1f\x00\x01\x01\x00\x00\x00\x00\x00\x05\x12\x00\x00\x00'
)
sec = FileSecInformation(sec_blob)
# Create a new ACE for a different user with R,W permissions
new_sid = b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
permissions = SUPPORTED_PERMISSIONS['R'] | SUPPORTED_PERMISSIONS['W']
ace_bytes = b'\x00\x00$\x00' + permissions.to_bytes(4, 'little') + new_sid
new_ace = FileNTACE(ace_bytes)
new_ace.action = 'grant'
result = SMBFileACL.insert_permission(sec, new_ace)
# Verify the ACE count increased
result_ntuser = FileNTUser(result[sec["OffsetToDACL"]:])
original_ntuser = FileNTUser(sec_blob[sec["OffsetToDACL"]:])
self.assertEqual(result_ntuser['NumACEs'], original_ntuser['NumACEs'] + 1)
def test_set_permissions_closes_file_on_permission_resolution_error(self):
"""Ensure set_permissions closes file handles if permission resolution fails"""
acl_manager = self.get_mock_smb_file_acl()
# Simulate opening file; ensure close_file is called on error
acl_manager.open_file = MagicMock(return_value=(1, 2))
acl_manager.close_file = MagicMock()
with patch.object(acl_manager, 'permissions_to_ace', side_effect=Exception('name resolution failed')):
with self.assertRaises(Exception):
acl_manager.set_permissions('SHARE', 'file.txt', 'NonExistentUser', 'R', 'grant')
acl_manager.close_file.assert_called_once()
def test_get_permissions_closes_file_on_query_error(self):
"""Ensure get_permissions closes file handles if querying file info fails"""
acl_manager = self.get_mock_smb_file_acl()
acl_manager.open_file = MagicMock(return_value=(1, 2))
acl_manager.close_file = MagicMock()
# Make queryInfo raise
acl_manager.connection._SMBConnection.queryInfo = MagicMock(side_effect=Exception('query failed'))
with self.assertRaises(Exception):
acl_manager.get_permissions('SHARE', 'file.txt')
acl_manager.close_file.assert_called_once()
def test_insert_permission_grant_existing_ace(self):
"""Test granting additional permissions to existing ACE (OR operation)"""
# Security descriptor with existing ACE
sec_blob = (
b'\x01\x00\x04\x80\x14\x00\x00\x000\x00\x00\x00\x00\x00\x00\x00L\x00\x00\x00'
b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xe9\x03\x00\x00'
b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\x01\x02\x00\x00'
b'\x02\x00\x90\x00\x05\x00\x00\x00'
b'\x00\x00$\x00\x9f\x01\x12\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
b'\x00\x10$\x00\xff\x01\x1f\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xe9\x03\x00\x00'
b'\x00\x10\x14\x00\xff\x01\x1f\x00\x01\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00'
b'\x00\x10\x18\x00\xff\x01\x1f\x00\x01\x02\x00\x00\x00\x00\x00\x05 \x00\x00\x00 \x02\x00\x00'
b'\x00\x10\x14\x00\xff\x01\x1f\x00\x01\x01\x00\x00\x00\x00\x00\x05\x12\x00\x00\x00'
)
sec = FileSecInformation(sec_blob)
original_ntuser = FileNTUser(sec_blob[sec["OffsetToDACL"]:])
# Grant X,D permissions to existing user (already has R,W)
existing_sid = b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
permissions = SUPPORTED_PERMISSIONS['X'] | SUPPORTED_PERMISSIONS['D']
ace_bytes = b'\x00\x00$\x00' + permissions.to_bytes(4, 'little') + existing_sid
new_ace = FileNTACE(ace_bytes)
new_ace.action = 'grant'
result = SMBFileACL.insert_permission(sec, new_ace)
# ACE count should remain the same
result_ntuser = FileNTUser(result[sec["OffsetToDACL"]:])
self.assertEqual(result_ntuser['NumACEs'], original_ntuser['NumACEs'])
# Verify permissions were OR'd (should now have R,W,X,D)
result_ace = FileNTACE(result_ntuser['Buffer'])
self.assertGreater(result_ace['SpecificRights'], 0)
def test_insert_permission_revoke_partial(self):
"""Test revoking some permissions from existing ACE (AND NOT operation)"""
# Security descriptor with ACE that has R,W,X,D permissions
sec_blob = (
b'\x01\x00\x04\x80\x14\x00\x00\x000\x00\x00\x00\x00\x00\x00\x00L\x00\x00\x00'
b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xe9\x03\x00\x00'
b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\x01\x02\x00\x00'
b'\x02\x00\x90\x00\x05\x00\x00\x00'
b'\x00\x00$\x00\xbf\x01\x13\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
b'\x00\x10$\x00\xff\x01\x1f\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xe9\x03\x00\x00'
b'\x00\x10\x14\x00\xff\x01\x1f\x00\x01\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00'
b'\x00\x10\x18\x00\xff\x01\x1f\x00\x01\x02\x00\x00\x00\x00\x00\x05 \x00\x00\x00 \x02\x00\x00'
b'\x00\x10\x14\x00\xff\x01\x1f\x00\x01\x01\x00\x00\x00\x00\x00\x05\x12\x00\x00\x00'
)
sec = FileSecInformation(sec_blob)
original_ntuser = FileNTUser(sec_blob[sec["OffsetToDACL"]:])
# Revoke W,X permissions (keep R,D)
existing_sid = b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
permissions = SUPPORTED_PERMISSIONS['W'] | SUPPORTED_PERMISSIONS['X']
ace_bytes = b'\x00\x00$\x00' + permissions.to_bytes(4, 'little') + existing_sid
revoke_ace = FileNTACE(ace_bytes)
revoke_ace.action = 'revoke'
result = SMBFileACL.insert_permission(sec, revoke_ace)
# ACE should still exist (not all permissions removed)
result_ntuser = FileNTUser(result[sec["OffsetToDACL"]:])
self.assertEqual(result_ntuser['NumACEs'], original_ntuser['NumACEs'])
def test_insert_permission_revoke_all(self):
"""Test revoking all permissions removes the ACE entirely"""
# Security descriptor with ACE that has only R,W permissions
sec_blob = (
b'\x01\x00\x04\x80\x14\x00\x00\x000\x00\x00\x00\x00\x00\x00\x00L\x00\x00\x00'
b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xe9\x03\x00\x00'
b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\x01\x02\x00\x00'
b'\x02\x00\x90\x00\x05\x00\x00\x00'
b'\x00\x00$\x00\x9f\x01\x12\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
b'\x00\x10$\x00\xff\x01\x1f\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xe9\x03\x00\x00'
b'\x00\x10\x14\x00\xff\x01\x1f\x00\x01\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00'
b'\x00\x10\x18\x00\xff\x01\x1f\x00\x01\x02\x00\x00\x00\x00\x00\x05 \x00\x00\x00 \x02\x00\x00'
b'\x00\x10\x14\x00\xff\x01\x1f\x00\x01\x01\x00\x00\x00\x00\x00\x05\x12\x00\x00\x00'
)
sec = FileSecInformation(sec_blob)
original_ntuser = FileNTUser(sec_blob[sec["OffsetToDACL"]:])
# Revoke R,W permissions (all the permissions this ACE has)
existing_sid = b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
permissions = SUPPORTED_PERMISSIONS['R'] | SUPPORTED_PERMISSIONS['W']
ace_bytes = b'\x00\x00$\x00' + permissions.to_bytes(4, 'little') + existing_sid
revoke_ace = FileNTACE(ace_bytes)
revoke_ace.action = 'revoke'
result = SMBFileACL.insert_permission(sec, revoke_ace)
# ACE should be removed (ACE count decreased)
result_ntuser = FileNTUser(result[sec["OffsetToDACL"]:])
self.assertEqual(result_ntuser['NumACEs'], original_ntuser['NumACEs'] - 1)
def test_insert_permission_delete_ace(self):
"""Test deleting an entire ACE"""
# Security descriptor with multiple ACEs
sec_blob = (
b'\x01\x00\x04\x80\x14\x00\x00\x000\x00\x00\x00\x00\x00\x00\x00L\x00\x00\x00'
b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xe9\x03\x00\x00'
b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\x01\x02\x00\x00'
b'\x02\x00\x90\x00\x05\x00\x00\x00'
b'\x00\x00$\x00\x9f\x01\x12\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
b'\x00\x10$\x00\xff\x01\x1f\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xe9\x03\x00\x00'
b'\x00\x10\x14\x00\xff\x01\x1f\x00\x01\x01\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00'
b'\x00\x10\x18\x00\xff\x01\x1f\x00\x01\x02\x00\x00\x00\x00\x00\x05 \x00\x00\x00 \x02\x00\x00'
b'\x00\x10\x14\x00\xff\x01\x1f\x00\x01\x01\x00\x00\x00\x00\x00\x05\x12\x00\x00\x00'
)
sec = FileSecInformation(sec_blob)
original_ntuser = FileNTUser(sec_blob[sec["OffsetToDACL"]:])
# Delete the first ACE
existing_sid = b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
ace_bytes = b'\x00\x00$\x00\x00\x00\x00\x00' + existing_sid
delete_ace = FileNTACE(ace_bytes)
delete_ace.action = 'delete'
result = SMBFileACL.insert_permission(sec, delete_ace)
# ACE count should decrease by 1
result_ntuser = FileNTUser(result[sec["OffsetToDACL"]:])
self.assertEqual(result_ntuser['NumACEs'], original_ntuser['NumACEs'] - 1)
class TestACLStructures(unittest.TestCase):
"""Test ACL structure classes"""
def test_acl_sid_repr(self):
"""Test SID string representation"""
# S-1-5-21-4190006963-579503432-2148133447-500
sid_bytes = b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
sid = ACL_SID(sid_bytes)
sid_str = str(sid)
self.assertTrue(sid_str.startswith('S-1-5-'))
parts = sid_str.split('-')
self.assertEqual(len(parts), 8) # S-1-5-21-x-x-x-500
def test_acl_sid_build_from_string(self):
"""Test building SID from string"""
sid_str = 'S-1-5-18'
sid = ACL_SID.build_from_string(sid_str)
self.assertIsInstance(sid, ACL_SID)
# Note: build_from_string has a known issue with Authority encoding
# but the basic structure should be present
self.assertEqual(sid['Revision'], 1)
def test_acl_sid_equality(self):
"""Test SID equality comparison"""
sid_bytes = b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
sid1 = ACL_SID(sid_bytes)
sid2 = ACL_SID(sid_bytes)
self.assertEqual(sid1, sid2)
self.assertEqual(hash(sid1), hash(sid2))
def test_file_ntace_flags(self):
"""Test ACE flag parsing"""
# Create ACE with inheritance flags
ace_bytes = b'\x00\x03$\x00\xff\x01\x1f\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
ace = FileNTACE(ace_bytes)
flags = ace.get_readable_ntace_flags()
self.assertIn('(OI)', flags) # Object Inherit
self.assertIn('(CI)', flags) # Container Inherit
def test_file_ntace_full_control_display(self):
"""Test that full control doesn't show redundant standard rights"""
# ACE with full control (F)
ace_bytes = b'\x00\x10$\x00\xff\x01\x1f\x00\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
ace = FileNTACE(ace_bytes)
ace_str = str(ace)
# Should show (I)(F) not (I)(R)(w)(D)(F)
self.assertIn('(F)', ace_str)
# Count the number of opening parentheses (should be 2: (I) and (F))
self.assertLessEqual(ace_str.count('('), 2)
def test_file_ntace_partial_permissions_display(self):
"""Test that partial permissions show all components"""
# ACE with R,W permissions (not full control)
permissions = SUPPORTED_PERMISSIONS['R'] | SUPPORTED_PERMISSIONS['W']
ace_bytes = b'\x00\x00$\x00' + permissions.to_bytes(4, 'little') + b'\x01\x05\x00\x00\x00\x00\x00\x05\x15\x00\x00\x00\xb3n\xbe\xf9H\x85\x8a"G\xea\t\x80\xf4\x01\x00\x00'
ace = FileNTACE(ace_bytes)
ace_str = str(ace)
# Should not have (F) since it's not full control
self.assertNotIn('(F)', ace_str)
def test_security_attributes_repr(self):
"""Test SecurityAttributes string representation"""
owner_sid = ACL_SID.build_from_string('S-1-5-18')
group_sid = ACL_SID.build_from_string('S-1-5-32-544')
attrs = SecurityAttributes('NT AUTHORITY\\SYSTEM', 'BUILTIN\\Administrators')
attrs.readable_dacls[owner_sid] = 'NT AUTHORITY\\SYSTEM:(I)(F)'
attrs_str = str(attrs)
self.assertIn('Owner:', attrs_str)
self.assertIn('Group:', attrs_str)
self.assertIn('ACLs:', attrs_str)
if __name__ == '__main__':
unittest.main()