Aujourd'hui je me suis retrouvé avec un besoin relativement basique : effectuer un mirroir d'un répertoire donné.
Jusque là, pas de problème, l'ami robocopy et son paramètre MIR sont plutôt sympas pour ce genre de choses :
robocopy.exe ".\Rep" "\\Dest\Mirroirs\Rep" /MIR /FFT
Seulement voilà, executer cette commande avec une destination ne contenant aucun des fichiers de la source équivaut bien évidemment à la copie de toutes les données, dans mon cas ça veut dire 9 sous-répertoires totalisant 60Go de données.
Vu le volume et la vitesse du réseau (un petit 100Mbps), je préfère faire une copie sous-répertoire par sous-répertoire vers la destination avant de mettre en place la commande de mirroir qui se chargera de garder tout celà à jour.
Rien de bien compliqué :
robocopy.exe ".\Rep\SousRep1" "\\Dest\Mirroirs\Rep\SousRep1" /ZB /E
Sauf que je suis du genre flemmard et que je n'ai pas envie de faire 9 fois la modification de la commande à la main.
L'idéal serait donc de pouvoir lancer 9 fois (je veux garder le choix du moment de démarrage de chaque copie) un script qui choissise automatiquement le premier sous-répertoire de la source qui n'existe pas encore dans la destination.
Facile avec PowerShell : vous disposez de base de la sympathique Cmdlet Compare-Object qui comme l'indique permet de faire des comparaisons entre les paramètres ReferenceObject et DifferenceObject.
C'est exactement ce qu'il nous faut, une liste de répertoires pouvant bien entendu servir d'entrée pour cette cmdlet :-)
Prenez ce code, Src\Rep contenant 9 sous-répertoires et Dest\Rep étant vide :
$srcDir="D:\Temp\Tests\Src\Rep"
$targetDir="D:\Temp\Tests\Dest\Rep"
Compare-Object $(dir $srcDir | where{$_.PSIsContainer}) $(dir $targetDir | where{$_.PSIsContainer})
Vous obtenez :
InputObject SideIndicator
----------- -------------
SousRep1 <=
SousRep2 <=
SousRep3 <=
SousRep4 <=
SousRep5 <=
SousRep6 <=
SousRep7 <=
SousRep8 <=
SousRep9 <=
SideIndicator indique de quel côté se trouve le répertoire qui manque de l'autre côté : ici nous voyons donc que les sous-répertoires n'existent que dans la source.
La même commande executée avec le sous-répertoire "SousRep1" dans le répertoire cible donnera la sortie suivante :
InputObject SideIndicator
----------- -------------
SousRep2 <=
SousRep3 <=
SousRep4 <=
SousRep5 <=
SousRep6 <=
SousRep7 <=
SousRep8 <=
SousRep9 <=
Si vous désirez voir les répertoires existants des 2 côtés, le paramètre IncludeEqual est fait pour vous.
Mais là ça ne nous intéresse bien évidemment pas, nous allons de toute façon filtrer sur SideIndicator = "<=".
Dans le cas qui nous intéresse l'utilisation sera la suivante :
# répertoire source
$srcDir="D:\Temp\Tests\Src\Rep"
# répertoire cible
$targetDir="D:\Temp\Tests\Dest\Rep"
# définition du template de commande pour Robocopy
$robocopyCommand='robocopy.exe "$srcDir\$currentFolder" "$targetDir\$currentFolder" /ZB /E'
# Execution :
# 1) Comparaison des listes des répertoires
# 2) Filtrage pour obtenir seulement la liste de ceux absent de la destination
# 3) Sélection du premier qui se trouve dans ce cas
# 4) Execution de la commande Robocopy pour effectuer la copie
Compare-Object $(dir $srcDir | where{$_.PSIsContainer}) $(dir $targetDir | where{$_.PSIsContainer}) | where {$_.SideIndicator -eq "<="} | select -First 1 | % { $currentFolder=$_.InputObject; Invoke-Expression -Command $robocopyCommand }
Ce script lancera ainsi une commande différente à chaque execution :
- robocopy.exe "D:\Temp\Tests\Src\Rep\SousRep1" "D:\Temp\Tests\Dest\Rep\SousRep1" /ZB /E
- robocopy.exe "D:\Temp\Tests\Src\Rep\SousRep2" "D:\Temp\Tests\Dest\Rep\SousRep2" /ZB /E
- etc
Ce qui me permet de faire ma mise en place en 9 fois sans me fatiguer plus que ça.
L'exemple pris ici n'est pas forcément parlant (et utile) pour tout le monde mais je pense qu'il s'agit cependant d'un exemple assez intéressant de l'utilisation de Compare-Object.
Bon scripting.
J'ai l'impression que pas mal de personnes sont parties sur une fausse idée avec ce problème d'injection SQL : certains ont l'air de penser qu'il s'agit uniquement d'un problème rencontré avec les applications dotées d'une interface utilisateur web (dans l'écosystème qui nous intéresse : ASP.NET, et toute les technologies reposant dessus).
L'injection SQL est uniquement un problème de développeur Web ?
Ce n'est absolument pas le cas : une application WinForm/WPF/Console/... sera impactée elle aussi.
Certes une application web offre probablement un risque d'exploitation de la faille plus élevé de par son exposition à un plus grand nombre de sources d'attaques, et certainement un nombre de points d'entrées plus important (zones de saisie, querystring, cookies, etc), mais il n'en demeure pas moins que l'utilisateur agissant de l'intérieur n'est pas plus digne de confiance qu'un anonyme sur le réseau (de l'entreprise ou internet).
Mais qu'est ce que l'injection SQL ?
L'attaque par injection SQL est une attaque reposant sur une faille de sécurité (défaut de vérification et sécurisation des entrées) dont le but à parvenir à provoquer l'exécution d'un code malicieux, initialement non prévu par le système vulnérable.
Pour cela, on procède tout simplement par insertion de ce code dans des chaînes de caractères qui seront par la suite utilisées pour bâtir un ordre SQL envoyé au serveur SQL pour exécution, en s'arrangeant pour qu'au final l'insertion dans une requête SQL rende notre code exécutable. Le serveur SQL n'a aucune raison valable de ne pas l'exécuter à partir du moment où il est valide.
Il se présente donc notamment si vous utilisez certains types d'informations pour les insérer dans des requêtes SQL sans prendre de précautions particulières :
- saisies par l'utilisateur dans un formulaire (quelle que soit la technologie utilisée, pas forcément web)
- provenant de champs cachés dans le formulaire
- provenant de paramètre d'url (querystring)
- provenant de cookies
- ...
Mais recadrons les choses différemment : le problème d'injection SQL n'est pas seulement lié à un type d'interface de saisie, il n'est même pas lié seulement au fait que la donnée est saisie : il est lié à la donnée en elle même.
Le souci peut très bien se présenter dans un processus sans intervention humaine directe, avec par exemple traitement de données issues de fichier CSV/XML/... provenant de sources diverses. Ces données ont donc potentiellement été traitées par un être humain à un lointain bout de la chaîne, directement ou pas : le texte manipulé peut provenir d'une opération d'OCR par exemple.
Un autre point important à garder à l'esprit est que l'attaque par injection SQL n'est pas forcément à effet direct lorsque l'utilisateur saisi son code "malicieux" au travers du moyen approprié : ses effets peuvent être déclenchés durant toute la durée de vie de la donnée.
Seulement un problème de sécurité ?
Hormis le côté sécurité (vol, destruction, etc) du problème, il y a un autre aspect de la chose identique en tout point hormis le côté volontaire qui caractérise l'attaque par injection SQL : la corruption involontaire de l'ordre SQL. Cet aspect là, tout le monde doit le connaitre.
Certes il ne s'agit pas directement à proprement parler d'injection SQL vu qu'il ne s'agit pas réellement d'une attaque mais le fond est le même, et l'existence de ce problème de corruption rend l'attaque par injection SQL possible.
Un exemple courant est celui tout simple de la gestion de personnes : vous enregistrez des noms et prénoms, avec un code de ce genre pour la production du code SQL :
// NE PAS UTILISER CECI ! / DON'T USE THAT !
String query = String.Format(CultureInfo.InvariantCulture,
"INSERT INTO [MonSchema].[MaTable] ([FirstName], [LastName]) VALUES ('{0}', '{1}');",
firstName,
lastName
);
Pas de chance, un beau jour vous devez enregistrer les informations d'une personne dont le nom comporte une apostrophe, et la requête générée a alors cet aspect là :
INSERT INTO [MonSchema].[MaTable] ([FirstName], [LastName]) VALUES ('Jean', 'D'upont');
Dans le meilleur des cas nous avons un ordre invalide et une erreur d'exécution, causant probablement des blocages, pertes financières, etc le temps de corriger le code mais peut être pas de corruption/destruction de données.
Par contre dans le pire des cas nous avons involontairement un code exécutable par le serveur différent de celui que nous avions prévu, qui ne sera peut être pas détecté dans l'immédiat si les effets produits ne sont pas flagrants, mais qui peut donc présenter un fort risque de corruption/perte de données.
Imaginez ici que l'opération d'OCR d'où provient le texte à persister porte sur un livre parlant de SQL, avec un exemple de code montrant comment supprimer les enregistrements de toutes les tables de la base courante...
Pour la suite de ce post, je considèrerais que les deux aspects du problème ne font qu'un, d'ailleurs les solutions pour l'un empêchent l'autre de se produire.
Les exemples sont quant à eux basés sur .NET (en C#) et SQL Server, mais le problème ne touche bien évidemment pas que ces technologies là.
Les solutions ?
Comment palier à ce problème ?
"Nettoyer" soit même les entrées est illusoire : vous ne connaissez probablement pas toutes les subtilités des différents moteurs de base de données, et ces moteurs sont de toute façon amenés à évoluer. "Nous modifierons le code à ce moment là" n'est pas une réponse valide : le code risque de mal vieillir.
Attention, soyons clair, je parlais bien ici de nettoyage en vue d'éviter la corruption de l'ordre SQL, pas de la nécessaire validation des entrées qui n'est pas directement attachée au problème dont nous parlons.
Il s'agit par exemple de la vérification des tailles, longueurs : si vous offrez une zone "commentaires", il y a des chances que vous ayez besoin pour elle de plus de 4000 caractères, auquel cas elle sera sans doute persistée en base sous forme d'un type nvarchar(MAX). Mais ça ne veut pas pour autant dire que vous voulez que la personne puisse envoyer 2Go de texte en base.
Les solutions en général proposées sont :
- utiliser des requêtes paramétrées
- utiliser des procédures stockées
Nous n'entrerons pas ici dans le débat de fond pour ou contre l'utilisation de procédures stockées, ce n'est pas le sujet.
A la liste précédente, nous pouvons ajouter : utiliser un outil de mapping objet/relationnel. Mais nous nous assurerons que le code SQL qu'il génère est bien évidemment paramétré et non pas basé sur de bêtes concaténations. Il ne s'agit pas de déporter le problème loin de nos yeux, mais d'ajouter une chance supplémentaire que les développeurs finaux ne fassent pas d'erreurs.
Cet outil va donc au final reposer sur une des solutions proposées, et donc est plus une couche supplémentaire qu'une solution directe. Sur le sujet qui nous concerne au travers de ce post, il aura surtout l'avantage de permettre aux architectes de limiter encore plus les risques de dérapage de la part des personnes qui exécutent le travail.
Est ce que ces 2 solutions se suffisent à elles mêmes ? Est ce que le simple fait de les utiliser suffit pour garantir la sécurité des données ? Non, il faut réellement que les personnes qui vont intervenir sur l'accès aux données comprennent ce problème.
Concernant les requêtes paramétrées, l'élément le plus proche de la requête dynamique habituelle (et dangereuse), et donc le plus simple à mettre en oeuvre à la place de cette dernière, il n'y a pas (à ma connaissance) grand chose de plus à faire pour sécuriser un peu plus.
Si la requête effectue un ordre INSERT, il faut que l'utilisateur ait directement ce droit sur les objets cibles.
Concernant les procédures stockées, il y a plus à dire. Le simple fait de déporter l'ordre INSERT dans une procédure stockée ne vous permet pas de passer magiquement d'un risque majeur à un risque zéro : encore faut t'il que l'appel de la procédure soit effectué de façon... paramétrée. En effet au final l'utilisation de procédures stockées est plus une couche supplémentaire qu'une solution directe au problème.
Rappelons qu'il y a au moins 2 moyens d'exécuter une procédure stockée depuis du code .NET : utiliser directement les facilités offertes par les objets d'accès aux données au travers de IDbCommand.CommandType en lui affectant CommandType.StoredProcedure, ou utiliser l'ordre EXECUTE dans un requête tout ce qu'il y a de plus commun.
On a tendance à oublier ce second moyen, mais le danger est pourtant bien à ce niveau là.
En utilisant CommandType.StoredProcedure, les données seront forcément spécifiée via l'implémentation de IDataParameter spécifique au provider utilisé, alors qu'au contraire avec l'utilisation de l'ordre EXECUTE vous avez le risque qu'un de vos développeurs écrive quelque chose de ce genre :
// NE PAS UTILISER CECI ! / DON'T USE THAT !
String query = String.Format(CultureInfo.InvariantCulture,
"EXECUTE [MonSchema].[AddPerson] @FirstName='{0}', @LastName='{1}';",
firstName,
lastName
);
Du coup, vous avez ici un formidable exemple de fausse impression de sécurité : la personne a utiliser une procédure stockée, c'est donc sécurisé. Ce n'est bien sûr pas du tout le cas, la requête ayant cet aspect là pour notre ami Jean D'upont :
EXECUTE [MonSchema].[AddPerson] @FirstName='Jean', @LastName='D'upont';
Placez maintenant un ordre SQL là où il faut...
Vous devez donc bien faire attention à la façon dont sont compris les conseils que vous donnez.
C'est là qu'on arrive sur un autre aspect à prendre en compte lorsque l'on a opté pour l'utilisation exclusive de procédures stockées : la seule permission dont a réellement besoin l'identité utilisée (qui n'a bien entendu pas reçu le rôle db_owner, n'est ce pas) par l'application cliente pour l'accès à la base de données est EXEC sur ces fameuses procédures et rien d'autre, limitant ainsi les impacts d'une éventuelle attaque réussie.
C'est là que vous aurez besoin de dialoguer un peu avec votre DBA préféré, il doit aimer jouer avec ces choses là.
Il s'agit ici d'une autre "solution" directement couplée à l'utilisation de procédures stockées qu'on voit parfois abordée quand on parle de ce problème d'injection SQL : utiliser des permissions en exécution seule.
Je ne l'ai pas citée plus haut car ce n'en est pas réellement une. En effet elle ne permet en rien de résoudre directement le problème mais elle vient plutôt en complément et permet en partie de limiter les impacts d'une attaque réussie.
Attention, comprenons nous bien : la limitation des droits de l'utilisateur à de simples permissions en exécution ne vous permettrons pas pour autant de faire l'appel de la procédure stockée n'importe comment en tout sécurité.
En reprenant notre exemple précédent, si le code SQL injecté par l'assaillant est un ordre INSERT/UPDATE/etc sur une table, il échouera. Mais s'il s'agit encore de notre WHILE, il sera exécuté, provoquant une consommation excessive de ressources.
L'attaquant pourra aussi se reposer sur l'appel d'autres procédures stockées auxquelles à accès l'utilisateur, qui lui permettront peut être d'altérer/détruire les données.
Il se peut aussi qu'au travers d'une attaque il puisse accéder à un serveur lié et que cette liaison aie été effectuée, pour diverses raisons, avec des credentials possédant un niveau de privilèges plus élevé.
Sans parler des manipulations qui pourraient permettre d'arriver à une élévation de privilèges.
De manière générale limiter les privilèges de l'utilisateur au strict nécessaire n'est jamais une mauvaise chose (par exemple empêcher l'utilisation de choses comme xp_cmdshell, Database Mail, ... si l'utilisateur n'a aucune raison valable d'y avoir accès), mais vous ne pouvez pas considérer cette seule action comme suffisante.
J'ai vérifié le code traitant des données externes, je peux me reposer sur mes lauriers maintenant ?
Absolument pas !
Souvenez vous, j'ai dit plus haut que l'attaque n'était pas forcément à effet direct : si vous avez fait en sorte qu'un code malicieux saisi ne soit pas exécuté lors de l'enregistrement des données en base, vous ne pouvez pas arrêter oublier l'injection SQL et penser que vos données sont maintenant dénuées de tout risque.
Imaginez que votre utilisateur s'est identifié en tant que "Jean" / "Dupont'); WHILE 1=1 DECLARE @nb int; --".
Grâce à votre enregistrement des données avec une requête paramétrée, il dispose maintenant d'un nom assez ridicule dans votre application. Mais justement, son nom est bel et bien "Dupont'); WHILE 1=1 DECLARE @nb int; --" en base, sans aucune conséquence réelle pour l'instant vu que c'est une donnée.
Mais que se passe t'il si vous partez du principe qu'une fois en base vos données sont saines et que fort de ce sentiment vous utilisez une concaténation de chaînes pour bâtir un ordre au lieu de faire encore et toujours une requête paramétrée ?
Dans notre cas le "WHILE 1=1 DECLARE @nb int;" devient exécutable, vous aimez les boucles infinies ? (oui, il y a des timeouts, mais tout de même).
La règle est toujours aussi simple : si vous devez utiliser des données provenant de votre base pour bâtir d'autres requêtes, utilisez des paramètres / procédures stockées (correctement appelées). Et de toute façon, comme dit plus haut, vous n'êtes toujours pas à l'abri d'un apostrophe légitime, donc pourquoi prendre ce risque d'obtenir un ordre SQL invalide même si les données stockées sont dignes de confiance (si ça arrive réellement...) ? Et repensez aussi au coup de l'OCR...
Et dans le code des procédures stockées ? (ou le restant du batch d'une requête paramétrée)
Voilà un dernier point auquel on ne pense pas forcément, et pourtant le risque est bien là : vous ne devez pas faire n'importe quoi non plus dans le code SQL utilisant les paramètres, que ce soit un simple batch ou une procédure stockée, fonction, ...
La simple utilisation de procédures stockées (et des paramètres en général) ne vous garanti pas que votre donnée est définitivement saine, ça vous garanti juste que la donnée sera transmise en tant que tel.
Pour illustrer, prenons l'exemple d'une procédure stockée permettant de faire une recherche des personnes dont le nom commence par celui d'une autre, et que pour une raison valable (dans l'exemple courant il n'y en a pas réellement) vous effectuez cette recherche au moyen d'une requête dynamique définie dans le corps de la procédure :
-- NE PAS UTILISER CECI ! / DON'T USE THAT
CREATE PROCEDURE [MonSchema].[FindPersonBAD]
(
@LastName nvarchar(256)
)
AS
BEGIN
-- NE PAS UTILISER CECI ! / DON'T USE THAT
DECLARE @sql nvarchar(4000);
SET @sql = N'SELECT [FirstName], [LastName]
FROM [MonSchema].[MaTable]
WHERE [LastName] LIKE ''' + @LastName + N'%''';
EXECUTE (@sql);
-- NE PAS UTILISER CECI ! / DON'T USE THAT
END
Si jamais un agresseur a prévu ce genre de cas, et que son nom est "Dupont%'; WHILE 1=1 DECLARE @nb int; --", vous venez une nouvelle fois de revivre le coup de la boucle infinie (certes vos DBA, si DBA il y a, ont probablement limités les effets de ce code précis en limitant la durée maximum d'exécution des requêtes mais quand même...).
Si ça ne vous suffit pas, imaginez un remplacement de la boucle par un code plus "sympathique", comme un ordre UPDATE : corruption d'informations et impact sur les performances en cas de table très volumineuse.
Si vous devez vraiment utiliser du SQL dynamique dans votre code SQL, ayez au moins le réflexe de passer par des paramètres : la procédure sp_executesql vous permet d'y arriver très simplement :
CREATE PROCEDURE [MonSchema].[FindPerson]
(
@LastName nvarchar(256)
)
AS
BEGIN
-- TODO : valider les entrées
DECLARE @sql nvarchar(4000);
SET @sql = N'SELECT [FirstName], [LastName]
FROM [MonSchema].[MaTable]
WHERE [LastName] LIKE @Name+''%''';
EXECUTE sp_executesql
@stmt = @sql,
@params = N'@Name nvarchar(256)',
@Name = @LastName;
END
Bien sûr, dans l'hypothèse que le SQL dynamique soit réellement nécessaire : dans le cas contraire, passez vous en.
Et là où on ne peut vraiment pas utiliser de paramètres ?
Il peut se présenter des cas pour lesquels utiliser un paramètre directement dans la requête n'est réellement pas possible, comme par exemple avec une clause TOP avec des versions de SQL Server inférieures à SQL Server 2005.
Dans ce cas vous devrez vous même assurer la sécurité, et donc prendre les mesures qui s'imposent : validation des types, validation des longueurs, ...
Dans le cas présent, il y a de fortes chances que votre valeur aie été passée à la procédure stockée sous forme d'un paramètre type int/bigint/float, mais n'oubliez pas de valider la plage de valeurs possible : si dans votre esprit la taille des pages affichées par votre application peut aller de 10 à 100 lignes, un attaquant aura peut être l'envie d'en demander quelques millions...
Si c'est du SQL dynamique généré côté client, n'insérez pas directement la valeur de filtrage à partir d'une chaîne : passez par Int32/Int64/Double (via TryParse si disponible), ça vous permettra de valider le type et la plage assez facilement.
Si la donnée est typée texte, ne cédez pas pour autant à la fatalité : par exemple s'il s'agit de manipuler des identifiants d'objets, vérifiez que la valeur qui vous a été spécifiée est bien celle d'un objet existant (voir OBJECT_ID, OBJECT_NAME, OBJECT_SCHEMA_NAME, etc)
Bonne revue de code.
Ces temps ci je vois certains s'extasier sur une "nouvelle" fonctionnalité de VS2008 : les Tracepoint (points de trace).
Eh bien ce n'est pas nouveau, je vous en avais parler en mai 2006, pour Visual Studio 2005 donc : Les Tracepoint ? C'est sympa !
Ce post ayant relativement mal vécu le changement de skin du blog (les images sont tronquées), je vais le représenter ci dessous :-)
Il s'agit, en gros, de l'équivalent d'un point d'arrêt sur lequel vous allez pouvoir notamment choisir d'envoyer un message vers la sortie du debugger, et, partie la plus intéressante, de continuer ou non l'exécution sans marquer d'arrêt.
Dans ce cas, on peut comparer le fonctionnement à l'insertion d'un appel à System.Diagnostics.Debug.WriteLine/System.Diagnostics.Trace.WriteLine, mis à par :
- vous n'avez pas besoin de modifier le code
- l'exécution ne se fait que si le debugger est attaché
- la suppression des constantes DEBUG/TRACE ne supprime pas l'affichage du message si le debugger est attaché (il ne s'agit pas d'une compilation conditionnelle, mais bien d'une fonction propre à Visual Studio)
- vous avez accès aux possibilités de réglages sur les breakpoint (test de condition, nombre d'accès, etc etc)
Au lieu de modifier votre code comme ceci :
Vous pouvez maintenant ajouter un Tracepoint :
Soit en partant de zéro en passant par "Insérer un point de trace" :
Soit en transformant un point d'arrêt existant via "Lorsqu'il est atteint..." (Aaah, le bon vieux F9 ;-) ) :
Il vous suffit de renseigner les champs comme ceci :

Vous aurez ainsi en sortie :
Prenons maintenant un cas "plus poussé", c'est à dire celui du travail dans une méthode dont le passage en arrière plan, y compris sur un break donc, provoque un nouveau passage, qui provoque un nouveau passage, qui provoque un nouveau passage, qui [...] (Paint, Focus etc).
Soit un code de dessin de ce genre (très poussé, lui aussi) :
Vous voulez afficher sur votre sortie les coordonnées du point de dessin, mais seulement si une CheckBox est cochée.
Avant, vous pouviez écrire ce genre de chose :
Maintenant, il vous suffit de définir votre action comme ceci :

Couplée avec la condition suivante pour le point d'arrêt :

Et le code n'a pas changé :
Sympa, non ?