From a3b8746ec5fc41dd7f7368092b413df4993e9649 Mon Sep 17 00:00:00 2001 From: chryzsh Date: Thu, 27 Aug 2026 16:00:58 +0200 Subject: [PATCH 1/3] Add get policies command and harden policy retrieval --- Program.cs | 124 +++++++++++++++++++++++++++----------- lib/Helpers.cs | 19 ++++++ lib/MgmtPointMessaging.cs | 120 ++++++++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 36 deletions(-) diff --git a/Program.cs b/Program.cs index ce6c59e..a510e10 100644 --- a/Program.cs +++ b/Program.cs @@ -544,42 +544,39 @@ static void Main(string[] args) getSecretsFromPolicy.Handler = CommandHandler.Create( (string managementPoint, string siteCode, string certificate, string clientId, string mediaId, string outputFile, string password, string registerClient, string username) => { - if (managementPoint == null || siteCode == null) - { - (managementPoint, siteCode) = ClientWmi.GetCurrentManagementPointAndSiteCode(); - } - if (!string.IsNullOrEmpty(managementPoint) && !string.IsNullOrEmpty(siteCode)) - { - if (!string.IsNullOrEmpty(certificate) && !string.IsNullOrEmpty(mediaId)) + ResolveClientAndInvoke(managementPoint, siteCode, certificate, clientId, username, password, registerClient, outputFile, + MgmtPointMessaging.GetSecretsFromPolicies, + (resolvedManagementPoint, resolvedSiteCode) => { - string szHTTPProxyAddress = null; - MgmtPointMessaging.SendPolicyAssignmentRequestWithExplicitData(clientId, mediaId, certificate, managementPoint, siteCode, szHTTPProxyAddress); - } - else if (!string.IsNullOrEmpty(certificate) && !string.IsNullOrEmpty(clientId)) - { - MgmtPointMessaging.GetSecretsFromPolicies(managementPoint, siteCode, certificate, clientId, null, null, null, outputFile); - } - else if (!string.IsNullOrEmpty(certificate) && string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(certificate) && !string.IsNullOrEmpty(clientId)) - { - Console.WriteLine("[!] Both a certificate (-c) and SMS client GUID (-i) for a previously registered client must be specified when using this option"); - } - else if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(registerClient)) - { - MgmtPointMessaging.GetSecretsFromPolicies(managementPoint, siteCode, null, null, username, password, registerClient, outputFile); - } - else if (!string.IsNullOrEmpty(registerClient) && (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))) - { - Console.WriteLine("[!] Both a computer account name (-u) and computer account password (-p) must be specified when using the register client (-r) option"); - } - else if (Helpers.IsHighIntegrity()) - { - MgmtPointMessaging.GetSecretsFromPolicies(managementPoint, siteCode, certificate, clientId, username, password, registerClient, outputFile); - } - else - { - Console.WriteLine("[!] A client name to register (-r), computer account name (-u), and computer account password (-p) must be specified when the user is not a local administrator"); - } - } + if (!string.IsNullOrEmpty(certificate) && !string.IsNullOrEmpty(mediaId)) + { + string szHTTPProxyAddress = null; + MgmtPointMessaging.SendPolicyAssignmentRequestWithExplicitData(clientId, mediaId, certificate, resolvedManagementPoint, resolvedSiteCode, szHTTPProxyAddress); + return true; + } + return false; + }); + }); + + // get policies + var getPolicies = new Command("policies", "Request the full machine policy from a management point via HTTP, including policies not flagged \"Secret\" (get secrets only returns the latter)\n" + + " Requirements:\n" + + " - Domain computer account credentials\n" + + " OR\n" + + " - Local Administrators group membership on a client"); + getCommand.Add(getPolicies); + getPolicies.Add(new Option(new[] { "--certificate", "-c" }, "The encoded X509 certificate blob to use that corresponds to a previously registered device")); + getPolicies.Add(new Option(new[] { "--client-id", "-i" }, "The SMS client GUID to use that corresponds to a previously registered device and certificate")); + getPolicies.Add(new Option(new[] { "--management-point", "-mp" }, "The IP address, FQDN, or NetBIOS name of the management point to connect to (default: the current management point of the client running SharpSCCM)")); + getPolicies.Add(new Option(new[] { "--output-dir", "-o" }, "The directory to write each policy's XML to (one file per policy, named by policy ID); prints to console if omitted")); + getPolicies.Add(new Option(new[] { "--password", "-p" }, "The password for the specified computer account")); + getPolicies.Add(new Option(new[] { "--register-client", "-r" }, "The name of the device to register as a new client (required when user is not a local administrator)")); + getPolicies.Add(new Option(new[] { "--username", "-u" }, "The name of the computer account to register the new device record with, including the trailing \"$\"")); + + getPolicies.Handler = CommandHandler.Create( + (string managementPoint, string siteCode, string certificate, string clientId, string outputDir, string password, string registerClient, string username) => + { + ResolveClientAndInvoke(managementPoint, siteCode, certificate, clientId, username, password, registerClient, outputDir, MgmtPointMessaging.GetPolicies); }); var getSiteInfo = new Command("site-info", "Get information about the site, including the site server name, from a domain controller via LDAP"); @@ -1402,6 +1399,61 @@ static void Main(string[] args) Console.WriteLine(ex.InnerException); } } - } + } + + // Shared by "get secrets" and "get policies": resolves the management point/site code if not + // provided, then picks the right client-auth path (existing cert+client-id, register a new + // client, or fall back to the local machine's own cert when running as admin) and invokes the + // matching MgmtPointMessaging method. A caller with an extra auth path of its own (e.g. "get + // secrets"'s PXE cert+media-GUID flow) can handle it via trySpecialCase, checked after + // resolution but before the standard ladder - matching where that check ran previously. + static void ResolveClientAndInvoke( + string managementPoint, + string siteCode, + string certificate, + string clientId, + string username, + string password, + string registerClient, + string outputPath, + Action invoke, + Func trySpecialCase = null) + { + if (managementPoint == null || siteCode == null) + { + (managementPoint, siteCode) = ClientWmi.GetCurrentManagementPointAndSiteCode(); + } + if (!string.IsNullOrEmpty(managementPoint) && !string.IsNullOrEmpty(siteCode)) + { + if (trySpecialCase != null && trySpecialCase(managementPoint, siteCode)) + { + return; + } + if (!string.IsNullOrEmpty(certificate) && !string.IsNullOrEmpty(clientId)) + { + invoke(managementPoint, siteCode, certificate, clientId, null, null, null, outputPath); + } + else if (!string.IsNullOrEmpty(certificate) && string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(certificate) && !string.IsNullOrEmpty(clientId)) + { + Console.WriteLine("[!] Both a certificate (-c) and SMS client GUID (-i) for a previously registered client must be specified when using this option"); + } + else if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password) && !string.IsNullOrEmpty(registerClient)) + { + invoke(managementPoint, siteCode, null, null, username, password, registerClient, outputPath); + } + else if (!string.IsNullOrEmpty(registerClient) && (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))) + { + Console.WriteLine("[!] Both a computer account name (-u) and computer account password (-p) must be specified when using the register client (-r) option"); + } + else if (Helpers.IsHighIntegrity()) + { + invoke(managementPoint, siteCode, certificate, clientId, username, password, registerClient, outputPath); + } + else + { + Console.WriteLine("[!] A client name to register (-r), computer account name (-u), and computer account password (-p) must be specified when the user is not a local administrator"); + } + } + } } } diff --git a/lib/Helpers.cs b/lib/Helpers.cs index cb8f754..17675fe 100644 --- a/lib/Helpers.cs +++ b/lib/Helpers.cs @@ -347,11 +347,30 @@ public static void DecompressXMLNodes(System.Xml.XmlNode xmlNode) Array.Copy(decompressedBytes, 2, decompressedXMLBytes, 0, decompressedBytes.Length - 2); szDecompressedStr = System.Text.Encoding.Unicode.GetString(decompressedXMLBytes); } + else + { + // Content didn't start with the UTF-16LE BOM we know how to decode; leaving the node's + // raw (still-compressed) content in place rather than silently dropping it unannounced. + Console.WriteLine($"[!] Decompressed a \"{compressionNode.Name}\" node's content but it didn't start with the expected UTF-16 BOM, so it was left un-decompressed"); + } // Update node content if (szDecompressedStr.Length > 0) { // remove "\r", "\n", "\t", etc. string szCleanedXmlStr = new string(szDecompressedStr.Where(c => !char.IsControl(c)).ToArray()); + // Decompressed content is often itself a full XML document starting with its own + // "" declaration. Since it's about to become InnerXml of an existing + // element rather than a standalone document, a nested declaration would make the + // final serialized document invalid (XML disallows a declaration except at the very start). + string szTrimmedXmlStr = szCleanedXmlStr.TrimStart(); + if (szTrimmedXmlStr.StartsWith(""); + if (declEndIndex >= 0) + { + szCleanedXmlStr = szTrimmedXmlStr.Substring(declEndIndex + 2); + } + } compressionNode.InnerXml = szCleanedXmlStr; // Recursive decompress foreach (System.Xml.XmlNode childNode in compressionNode.ChildNodes) diff --git a/lib/MgmtPointMessaging.cs b/lib/MgmtPointMessaging.cs index 486832f..f788b11 100644 --- a/lib/MgmtPointMessaging.cs +++ b/lib/MgmtPointMessaging.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography.Pkcs; using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Threading.Tasks; using System.Xml; // Configuration Manager SDK @@ -285,6 +286,125 @@ public static async void GetSecretsFromPolicy(PolicyAssignment policyAssignment, } } + public static void GetPolicies(string managementPoint, string siteCode, string encodedCertificate = null, string providedClientId = null, string username = null, string password = null, string registerClient = null, string outputDir = null) + { + // Same registration/auth flow as GetSecretsFromPolicies, but fetches every policy assignment instead of only ones flagged "Secret" + (MessageCertificateX509 signingCertificate, MessageCertificateX509 encryptionCertificate, SmsClientId clientId) = GetCertsAndClientId(managementPoint, siteCode, encodedCertificate, providedClientId, username, password, registerClient); + + if (signingCertificate != null && encryptionCertificate != null && clientId != null) + { + ConfigMgrPolicyAssignmentReply assignmentReply = SendPolicyAssignmentRequest(clientId, signingCertificate, managementPoint, siteCode); + + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + + foreach (PolicyAssignment policyAssignment in assignmentReply.ReplyAssignments.PolicyAssignments) + { + // Block until each policy is fully downloaded/parsed/written before moving to the next one; + // GetPolicy is async (it awaits the HTTP body read), and this loop is not, so without this + // the loop would race ahead into the next policy before the current one finishes. + GetPolicy(policyAssignment, managementPoint, clientId, encryptionCertificate, signingCertificate, outputDir).GetAwaiter().GetResult(); + } + } + } + + public static async Task GetPolicy(PolicyAssignment policyAssignment, string managementPoint, SmsClientId clientId, MessageCertificateX509 encryptionCertificate, MessageCertificateX509 signingCertificate, string outputDir = null) + { + bool isSecretPolicy = policyAssignment.Policy.Flags.ToString().Contains("Secret"); + + Console.WriteLine($"[+] Found policy{(isSecretPolicy ? " containing secrets" : string.Empty)}:"); + Console.WriteLine($" ID: {policyAssignment.Policy.Id}"); + Console.WriteLine($" Flags: {policyAssignment.Policy.Flags}"); + Console.WriteLine($" URL: {policyAssignment.Policy.Location.Value}"); + + HttpResponseMessage policyDownloadResponse = null; + try + { + string policyURL = policyAssignment.Policy.Location.Value.Replace("", managementPoint); + policyDownloadResponse = SendPolicyDownloadRequest(policyURL, clientId, signingCertificate); + } + catch (Exception ex) + { + Console.WriteLine($"[!] An exception occurred while trying to download policy {policyAssignment.Policy.Id}: {ex.Message}"); + return; + } + + if (policyDownloadResponse == null) + { + return; + } + + byte[] policyDownloadResponseBytes = await policyDownloadResponse.Content.ReadAsByteArrayAsync(); + Console.WriteLine($"[+] Received {policyDownloadResponseBytes.Length} byte response from server for policy {policyAssignment.Policy.Id}"); + + string policyBody; + if (isSecretPolicy) + { + try + { + policyBody = DecryptPolicyBody(policyDownloadResponseBytes, encryptionCertificate); + } + catch (Exception ex) + { + Console.WriteLine($"[!] An exception occurred while trying to decrypt policy {policyAssignment.Policy.Id}: {ex.Message}"); + return; + } + } + else + { + // Policies not flagged "Secret" are returned in plaintext rather than CMS-enveloped, so no decryption step is required. + // UTF-8 (not ASCII) since we have no guarantee the response is ASCII-only; UTF-8 decodes plain ASCII identically. + policyBody = Encoding.UTF8.GetString(policyDownloadResponseBytes).Replace("\0", string.Empty); + } + + if (string.IsNullOrEmpty(policyBody)) + { + return; + } + + XmlDocument policyXmlDoc = new XmlDocument(); + try + { + policyXmlDoc.LoadXml(TrimToXmlStart(policyBody)); + } + catch (XmlException ex) + { + Console.WriteLine($"[!] An exception occurred while trying to parse policy {policyAssignment.Policy.Id} as XML: {ex.Message}"); + return; + } + try + { + Helpers.DecompressXMLNodes(policyXmlDoc); + } + catch (Exception ex) + { + Console.WriteLine($"[!] An exception occurred while trying to decompress policy {policyAssignment.Policy.Id}: {ex.Message}"); + return; + } + + if (!string.IsNullOrEmpty(outputDir)) + { + string safePolicyId = string.Join("_", policyAssignment.Policy.Id.Split(Path.GetInvalidFileNameChars())); + string outputFile = Path.Combine(outputDir, $"{safePolicyId}.xml"); + File.WriteAllText(outputFile, policyXmlDoc.OuterXml); + Console.WriteLine($"[+] Wrote policy {policyAssignment.Policy.Id} to {outputFile}"); + } + else + { + Console.WriteLine(policyXmlDoc.OuterXml); + } + } + + // Server responses (CMS-decrypted or plaintext) sometimes carry a couple of leading garbage/BOM bytes before the XML declaration + private static string TrimToXmlStart(string rawBody) + { + string trimmed = rawBody.Trim(); + int startIndex = trimmed.IndexOf('<'); + return startIndex > 0 ? trimmed.Substring(startIndex) : trimmed; + } + public static MessageCertificateX509 LocalSmsEncryptionCertificate() { MessageCertificateX509 certificate = null; From e69dc97fe3fd7702f8297b90f02ee5121c7f69aa Mon Sep 17 00:00:00 2001 From: chryzsh Date: Thu, 27 Aug 2026 16:07:02 +0200 Subject: [PATCH 2/3] Add policy retrieval regression test and release notes --- Properties/AssemblyInfo.cs | 4 ++-- RELEASE_NOTES.md | 8 ++++++++ UnitTests/HelpersTests.cs | 39 ++++++++++++++++++++++++++++++++++++++ UnitTests/UnitTests.csproj | 4 +++- 4 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 UnitTests/HelpersTests.cs diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index e586576..bf56113 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -27,5 +27,5 @@ // Minor Version // Revision // -[assembly: AssemblyVersion("2.0.13")] -[assembly: AssemblyFileVersion("2.0.13")] +[assembly: AssemblyVersion("2.0.14")] +[assembly: AssemblyFileVersion("2.0.14")] diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a162ac5..cd5004b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,13 @@ # SharpSCCM Release Notes +### Version 2.0.14 (August 27, 2026) +##### New Commands +- Added `get policies` to retrieve all machine policy assignments +##### Changes +- Wait for policy downloads to complete before requesting the next policy +- Handle policy decompression and decoding failures without terminating the command +- Preserve valid XML when decompressed policy content includes an XML declaration + ### Version 2.0.13 (August 20, 2025) ##### Changes - Added --delay-exit option instead of holding open when debugging by default diff --git a/UnitTests/HelpersTests.cs b/UnitTests/HelpersTests.cs new file mode 100644 index 0000000..8fb3311 --- /dev/null +++ b/UnitTests/HelpersTests.cs @@ -0,0 +1,39 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Xml; + +namespace SharpSCCM.UnitTests +{ + [TestClass] + public class HelpersTests + { + [TestMethod] + public void DecompressXMLNodes_NestedXmlDeclaration_ExpandsWithoutDeclaration() + { + string nestedXml = "test"; + byte[] nestedXmlBytes = Encoding.Unicode.GetPreamble().Concat(Encoding.Unicode.GetBytes(nestedXml)).ToArray(); + byte[] compressedBytes; + + using (MemoryStream output = new MemoryStream()) + { + using (GZipStream compressor = new GZipStream(output, CompressionMode.Compress, true)) + { + compressor.Write(nestedXmlBytes, 0, nestedXmlBytes.Length); + } + compressedBytes = output.ToArray(); + } + + XmlDocument document = new XmlDocument(); + document.LoadXml($"{BitConverter.ToString(compressedBytes).Replace(\"-\", string.Empty)}"); + + Helpers.DecompressXMLNodes(document.DocumentElement); + + Assert.AreEqual("test", document.SelectSingleNode("/Root/Policy/Inner/Value").InnerText); + Assert.IsFalse(document.OuterXml.Contains(" + + @@ -104,4 +106,4 @@ - \ No newline at end of file + From b80352d68b1f58455484d180551a7c7afc0eb7cf Mon Sep 17 00:00:00 2001 From: chryzsh Date: Thu, 27 Aug 2026 19:10:31 +0200 Subject: [PATCH 3/3] Fix policy regression test syntax --- UnitTests/HelpersTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/UnitTests/HelpersTests.cs b/UnitTests/HelpersTests.cs index 8fb3311..8c382e2 100644 --- a/UnitTests/HelpersTests.cs +++ b/UnitTests/HelpersTests.cs @@ -28,7 +28,8 @@ public void DecompressXMLNodes_NestedXmlDeclaration_ExpandsWithoutDeclaration() } XmlDocument document = new XmlDocument(); - document.LoadXml($"{BitConverter.ToString(compressedBytes).Replace(\"-\", string.Empty)}"); + string compressedHex = BitConverter.ToString(compressedBytes).Replace("-", string.Empty); + document.LoadXml($"{compressedHex}"); Helpers.DecompressXMLNodes(document.DocumentElement);