반응형
CVE-2019-19844
0. 취약한 환경 버전
Django 3.0
1. 환경 구성
- python 3.8.10
- Ubuntu 20.04.1
- Django 3.0
- postgreSQL 12.8
POC를 위한 계정 정보 추가 (SHELL)
from django.contrib.auth import get_user_model
User = get_user_model()
User.objects.create_user('testuser', 'test@abc.com', 'test123')
2. POC
- test@abc.com -> Test@abc.com (대소문자 변경) 후 패스워드 리셋 이메일 인증을 진행한다
- Test@abc.com 이메일로 패스워드 리셋 URL이 전송 된것을 확인 할 수 있다.
3. 상세분석
2가지 취약성으로 인해 발생한다.
- 메일 식별 시 대소문자를 구분하지 않고 문자열만 일치하면 동일한 이메일로 판단한다.
- 메일 식별 후 메일 전송 시 DB의 메일이 아닌 입력 폼에서 받은 메일 기준으로 전송한다.
다음은 PasswordReset 기능이 명시 된 form.py 이다.
contrib/auth/forms.py
class PasswordResetForm(forms.Form):
email = forms.EmailField(
label=_("Email"),
max_length=254,
widget=forms.EmailInput(attrs={'autocomplete': 'email'})
)
def send_mail(self, subject_template_name, email_template_name,
context, from_email, to_email, html_email_template_name=None):
"""
Send a django.core.mail.EmailMultiAlternatives to `to_email`.
"""
subject = loader.render_to_string(subject_template_name, context)
# Email subject *must not* contain newlines
subject = ''.join(subject.splitlines())
body = loader.render_to_string(email_template_name, context)
email_message = EmailMultiAlternatives(subject, body, from_email, [to_email])
if html_email_template_name is not None:
html_email = loader.render_to_string(html_email_template_name, context)
email_message.attach_alternative(html_email, 'text/html')
email_message.send()
def get_users(self, email):
"""Given an email, return matching user(s) who should receive a reset.
This allows subclasses to more easily customize the default policies
that prevent inactive users and users with unusable passwords from
resetting their password.
"""
active_users = UserModel._default_manager.filter(**{
'%s__iexact' % UserModel.get_email_field_name(): email,
'is_active': True,
})
return (u for u in active_users if u.has_usable_password())
def save(self, domain_override=None,
subject_template_name='registration/password_reset_subject.txt',
email_template_name='registration/password_reset_email.html',
use_https=False, token_generator=default_token_generator,
from_email=None, request=None, html_email_template_name=None,
extra_email_context=None):
"""
Generate a one-use only link for resetting password and send it to the
user.
"""
email = self.cleaned_data["email"]
for user in self.get_users(email):
if not domain_override:
current_site = get_current_site(request)
site_name = current_site.name
domain = current_site.domain
else:
site_name = domain = domain_override
context = {
'email': email,
'domain': domain,
'site_name': site_name,
'uid': urlsafe_base64_encode(force_bytes(user.pk)),
'user': user,
'token': token_generator.make_token(user),
'protocol': 'https' if use_https else 'http',
**(extra_email_context or {}),
}
self.send_mail(
subject_template_name, email_template_name, context, from_email,
email, html_email_template_name=html_email_template_name,
)
1. 메일 식별 시 대소문자를 구분하지 않고 문자열만 일치하면 동일한 이메일로 판단한다.
def get_users(self, email):
"""Given an email, return matching user(s) who should receive a reset.
This allows subclasses to more easily customize the default policies
that prevent inactive users and users with unusable passwords from
resetting their password.
"""
active_users = UserModel._default_manager.filter(**{
'%s__iexact' % UserModel.get_email_field_name(): email,
'is_active': True,
})
return (u for u in active_users if u.has_usable_password())
2. 메일 식별 후 메일 전송 시 DB의 메일이 아닌 입력 폼에서 받은 메일 기준으로 전송한다.
def save(self, domain_override=None,
subject_template_name='registration/password_reset_subject.txt',
email_template_name='registration/password_reset_email.html',
use_https=False, token_generator=default_token_generator,
from_email=None, request=None, html_email_template_name=None,
extra_email_context=None):
"""
Generate a one-use only link for resetting password and send it to the
user.
"""
email = self.cleaned_data["email"]
for user in self.get_users(email):
if not domain_override:
current_site = get_current_site(request)
site_name = current_site.name
domain = current_site.domain
else:
site_name = domain = domain_override
context = {
'email': email,
'domain': domain,
'site_name': site_name,
'uid': urlsafe_base64_encode(force_bytes(user.pk)),
'user': user,
'token': token_generator.make_token(user),
'protocol': 'https' if use_https else 'http',
**(extra_email_context or {}),
}
self.send_mail(
subject_template_name, email_template_name, context, from_email,
email, html_email_template_name=html_email_template_name,
)
PasswordResetForm에서 send_mail에서 담고 있는 인자를 확인해 보면 이메일 전송 시 DB의 이메일이 아닌 사용자가 입력한 폼을 기준으로 email을 전송하는 것을 확인 할 수 있다.
4. 취약점 패치
1. 메일 식별 시 대소문자를 구분하지 않고 문자열만 일치하면 동일한 이메일로 판단한다.
def _unicode_ci_compare(s1, s2):
"""
Perform case-insensitive comparison of two identifiers, using the
recommended algorithm from Unicode Technical Report 36, section
2.11.2(B)(2).
"""
return unicodedata.normalize('NFKC', s1).casefold() == unicodedata.normalize('NFKC', s2).casefold()
이메일의 대소문자 구분을 위해 비교하는 함수를 추가 하였다.
해당 로직은 Unicode 기술 보고서 36섹션 2.11.2(B)(2)의 식별자 비교 프로세스를 사용하였다.
def get_users(self, email):
email_field_name = UserModel.get_email_field_name()
active_users = UserModel._default_manager.filter(**{
'%s__iexact' % UserModel.get_email_field_name(): email,
'%s__iexact' % email_field_name: email,
'is_active': True,
})
return (u for u in active_users if u.has_usable_password())
return (
u for u in active_users
if u.has_usable_password() and
#_unicode_ci_compare()를 사용하여 이메일이 동등한지 비교한다.
_unicode_ci_compare(email, getattr(u, email_field_name))
)
return 시 _unicode_ci_compare() 함수를 사용하여 이메일이 일치하는지 검증 한다.
DB에서 이메일을 검색 시에 도메인은 대소문자를 구분하지 않는 것이 원칙이므로 s__iexact 룩업 타입을 변경하지 않고 사용했다.
2. 메일 식별 후 메일 전송 시 DB의 메일이 아닌 입력 폼에서 받은 메일 기준으로 전송한다.
def save(self, domain_override=None,
subject_template_name='registration/password_reset_subject.txt',
email_template_name='registration/password_reset_email.html',
use_https=False, token_generator=default_token_generator,
from_email=None, request=None, html_email_template_name=None,
extra_email_context=None):
"""
Generate a one-use only link for resetting password and send it to the
user.
"""
email = self.cleaned_data["email"]
#email 필드 이름을 가져온다.
email_field_name = UserModel.get_email_field_name()
for user in self.get_users(email):
if not domain_override:
current_site = get_current_site(request)
site_name = current_site.name
domain = current_site.domain
else:
site_name = domain = domain_override
# context에 getattr() 함수를 이용해 검증된 email을 user_email에 삽입한다.
user_email = getattr(user, email_field_name)
context = {
'email': user_email,
'domain': domain,
'site_name': site_name,
'uid': urlsafe_base64_encode(force_bytes(user.pk)),
'user': user,
'token': token_generator.make_token(user),
'protocol': 'https' if use_https else 'http',
**(extra_email_context or {}),
}
self.send_mail(
subject_template_name, email_template_name, context, from_email,
user_email, html_email_template_name=html_email_template_name,
)
get_users로 이메일 검증을 한 후 검증한 이메일을 context에 삽입하여 서버 DB에 등록된 이메일로 전송되게 구현 하였다.
반응형