Skip to main content

Les dangers de setattr : éviter les vulnérabilités d’assignation de masse en Python

Écrit par
Headshot of Jack Hair

Jack Hair

blog feature pypi spoof

15 février 2023

0 minutes de lecture

L’assignation de masse, également appelée liaison automatique ou injection d’objets, désigne une catégorie de vulnérabilités qui surviennent lorsque des entrées utilisateur sont associées à des variables ou à des objets dans un programme.

Les vulnérabilités d’assignation de masse résultent souvent de l’ajout, par un attaquant, de champs inattendus à un objet afin de manipuler la logique d’un programme. C’est ce qui a provoqué une célèbre vulnérabilité d’authentification de GitHub : la fonctionnalité d’assignation de masse de Ruby on Rails permettait à un attaquant d’ajouter sa clé publique à l’organisation Rails de GitHub et de pousser un commit sur la branche master.

Cet article de blog a notamment été inspiré par la vulnérabilité Spring4Shell, une vulnérabilité d’exécution de code à distance (RCE) due à la fonctionnalité de liaison automatique intégrée au framework Spring. Cela nous a amenés à chercher si une vulnérabilité similaire pouvait exister dans l’écosystème Python.

Dans cet article, nous examinons les risques potentiels de la fonction setattr() de Python et présentons des moyens de prévenir les vulnérabilités d’assignation de masse dans votre application.

Présentation de setattr

En Python, une méthode couramment utilisée pour mettre en œuvre l’assignation de masse consiste à utiliser la fonction intégrée setattr(). Cette fonction prend un objet, un nom d’attribut et une valeur, puis définit l’attribut de l’objet avec la valeur fournie. Elle permet d’ajouter de nouveaux attributs et de modifier ceux qui existent déjà.

Bien que pratique, permettre à un utilisateur de définir la valeur de n’importe quel attribut d’un objet peut avoir des conséquences imprévues.

Premièrement, cela peut entraîner des vulnérabilités classiques liées à la logique de l’assignation de masse. Les champs d’objet qu’un utilisateur n’était pas censé pouvoir modifier deviennent alors modifiables.

Deuxièmement, les attributs modifiables avec setattr ne se limitent pas aux champs définis par le programmeur : il est également possible de définir des fonctions et des attributs intégrés, tels que __class__ et __dict__ . Contrairement à la vulnérabilité Spring4Shell, setattr ne peut pas définir les attributs imbriqués d’un objet, ce qui limite la portée d’une attaque. Cependant, il est facile de provoquer une exception au moment de l’appel à setattr en définissant __class__ sur une chaîne de caractères, ou plus tard lors de l’exécution du programme en écrasant une fonction de l’objet. Une exception provoquée de cette manière peut entraîner un comportement anormal du programme ou un déni de service.

Le code ci-dessous est un exemple simple qui utilise setattr pour créer un utilisateur. Avec l’entrée first_name=Sam,last_name=S, il affiche Sam S: Permission denied.

class User():
	def __init__(self, admin=False):
    		self.admin = admin

	def is_admin(self):
    		return "You are admin!" if self.admin else "Permission denied"

def parse_input(usr_in):
	return dict([tuple(kv.split("=", 1)) for kv in usr_in.split(",") if "=" in kv])

def create_user(info):
	user = User()
	for key, value in info.items():
    	setattr(user, key, value)
	return user

def main():
	user = create_user(parse_input(str(input())))
	if hasattr(user, "first_name") and hasattr(user, "last_name"):
    		print(f"{user.first_name} {user.last_name}: {user.is_admin()}")

if __name__ == "__main__":
	main()

Pour faire planter le programme en déclenchant une exception non interceptée, vous pouvez utiliser l’entrée first_name=Sam,last_name=S,is_admin=foo : elle écrase la fonction is_admin et la remplace par une chaîne, ce qui provoque une exception lorsque la fonction est appelée.

L’entrée first_name=Sam,last_name=S,admin=foo remplace l’attribut admin de l’objet User par la chaîne foo, car cette valeur est considérée comme vraie. Lorsque la fonction is_admin vérifie l’attribut admin, l’utilisateur est traité comme un administrateur et le message Sam S: You are admin! s’affiche.

Prévenir l’assignation de masse

Pour limiter les vulnérabilités d’assignation de masse, il est généralement conseillé de séparer les objets contenant des entrées utilisateur de ceux qui gèrent la logique interne du programme. Pour cela, créez des objets de transfert de données (DTO) qui ne contiennent que les champs qu’un utilisateur est autorisé à définir. En Python, c’est facile avec les dataclasses. Le code suivant montre comment un DTO permet de définir explicitement les champs autorisés. Si des champs incorrects sont spécifiés, une TypeError est levée, puis interceptée et gérée, ce qui empêche les attaques mentionnées ci-dessus.

from dataclasses import dataclass

@dataclass
class UserDTO():
	first_name: str
	last_name: str

class User():
	def __init__(self, dto, admin=False):
    		self.first_name = dto.first_name
    		self.last_name = dto.last_name
    		self.admin = admin

	def is_admin(self):
    		return "You are admin!" if self.admin else "Permission denied"

def parse_input(usr_in):
	return dict([tuple(kv.split("=", 1)) for kv in usr_in.split(",") if "=" in kv])

def create_user(info):
	try:
    		return User(UserDTO(**info))
	except TypeError:
    		return None

def main():
	user = create_user(parse_input(str(input())))
	if user:
    		print(f"{user.first_name} {user.last_name}: {user.is_admin()}")

if __name__ == "__main__":
	main()

La validation des entrées utilisateur permet également de prévenir les vulnérabilités d’assignation de masse. Comme toujours, mieux vaut valider les données à partir d’une liste de valeurs approuvées que de rechercher celles qui sont interdites. Le code suivant utilise toujours setattr, mais vérifie le nom de l’attribut par rapport à une liste d’attributs autorisés.

class User():
	allowed_fields = ["first_name", "last_name"]

	def __init__(self, admin=False):
    		self.admin = admin

	def is_admin(self):
    		return "You are admin!" if self.admin else "Permission denied"

def parse_input(usr_in):
	return dict([tuple(kv.split("=", 1)) for kv in usr_in.split(",") if "=" in kv])

def create_user(info):
	user = User()
	for key, value in info.items():
    		if key in User.allowed_fields:
        			setattr(user, key, value)
    		else:
        			return None
	return user

def main():
	user = create_user(parse_input(str(input())))
	if user:
    		print(f"{user.first_name} {user.last_name}: {user.is_admin()}")

if __name__ == "__main__":
	main()

Il est toujours préférable de préciser les entrées attendues par un programme. Toutefois, si vous devez gérer les entrées utilisateur avec davantage de flexibilité, il existe des solutions de remplacement à la fonction setattr. Le module collections de la bibliothèque standard Python propose deux classes utiles : collections.UserDict et collections.abc.MutableMapping. Vous pouvez en hériter pour ajouter des fonctionnalités aux associations clé-valeur. Si vous devez accéder aux valeurs par leurs attributs plutôt que par leurs clés, vous pouvez implémenter une méthode __getattr__ dans votre classe. Dans l’exemple ci-dessous, la classe User se comporte comme un dictionnaire et peut stocker tous les champs fournis par l’utilisateur. En redéfinissant __getattr__, lorsqu’un attribut n’est pas trouvé par le mécanisme habituel d’accès aux attributs, sa valeur est récupérée dans le dictionnaire des champs fournis par l’utilisateur. Ces champs restent ainsi accessibles comme des attributs, sans que les attributs de la classe puissent être écrasés par des entrées utilisateur.

from collections import UserDict

class User(UserDict):
	def __init__(self, info, admin=False):
    		self.admin = admin
    		super().__init__(info)

	def is_admin(self):
    		return "You are admin!" if self.admin else "Permission denied"

	def __getattr__(self, name):
    	if name in self:
     		   	return self[name]
    		else:
        		raise AttributeError("'User' object has no attribute '{}'".format(name))

def parse_input(usr_in):
	return dict([tuple(kv.split("=", 1)) for kv in usr_in.split(",") if "=" in kv])

def create_user(info):
	return User(info)

def main():
	user = create_user(parse_input(str(input())))

	if "first_name" in user and "last_name" in user:
    		print(f"{user.first_name} {user.last_name}: {user.is_admin()}")

if __name__ == "__main__":
	main()

Protéger vos bibliothèques Python

Concilier sécurité, simplicité et flexibilité peut être difficile en plein développement logiciel. Un élément qui semble inoffensif à première vue peut vous exposer, vous et votre application, à divers risques de sécurité. C’est pourquoi la sensibilisation et les méthodes de remplacement sont si importantes. Nous avons vu comment fonctionne setattr, quels sont les dangers de son utilisation dans vos bibliothèques Python et comment prévenir les vulnérabilités d’assignation de masse. Grâce à ces connaissances, vous êtes sur la bonne voie pour protéger vos applications Python contre les attaques et les violations de données. Pour en savoir plus, rendez-vous sur notre page d’accueil et créez un compte gratuit, ou réservez une démo pour découvrir comment Snyk peut protéger votre code, vos conteneurs, vos dépendances et votre infrastructure cloud.

Lancez-vous dans les compétitions Capture The Flag

Apprenez à résoudre des défis de capture du drapeau en regardant à la demande notre atelier virtuel d’initiation.