CVE-2021-21329 (published 2021-03-08T18:15Z)

RATCF is an open-source framework for hosting Cyber-Security Capture the Flag events. In affected versions of RATCF users with multi factor authentication enabled are able to log in without a valid token. This is fixed in commit cebb67b.

Severity: MEDIUM
CVSS v2 base score: 6.8 (AV:N/AC:M/Au:N/C:P/I:P/A:P)
CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-287
cwe_name: Improper Authentication
description: When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
extended_description: 
            
			
         
         

Fix information:
repo_url: https://github.com/ractf/core
commit_details: repo_url: https://github.com/ractf/core
author: David Cooke
author_date: 2021-02-26 11:41:20+00:00
msg: Merge pull request from GHSA-fw57-f7mq-9q85
repository: repo_url: https://github.com/ractf/core
repo_name: ractf/core
description: The Django backend running RACTF wargame events.
repo_language: Python
owner: ractf
file_changes: filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -150,7 +150,7 @@ class LoginTwoFactorView(APIView):
                     code.delete()
                     return self.issue_token(user)
 
-        return self.issue_token(user)
+        return FormattedResponse(status=HTTP_401_UNAUTHORIZED, d={'reason': 'login_failed'}, m='login_failed')
 
 
 class RegenerateBackupCodesView(APIView):

programming_language: Python
repo_url: https://github.com/ractf/core
commit_details: repo_url: https://github.com/ractf/core
author: David Cooke
author_date: 2020-08-10 12:17:54+01:00
msg: Refactor 2FA and prepare for fido support
repository: repo_url: https://github.com/ractf/core
repo_name: ractf/core
description: The Django backend running RACTF wargame events.
repo_language: Python
owner: ractf
file_changes: filename: basic_auth.py
change_type: ModificationType.MODIFY
diff: @@ -64,12 +64,7 @@ class BasicAuthRegistrationProvider(RegistrationProvider):
 class BasicAuthLoginProvider(LoginProvider):
     name = 'basic_auth'
 
-    #TODO: These fields don't do anything yet, but they will eventually correlate to the kwargs in login_user
-    username = serializers.CharField(max_length=50)
-    password = serializers.CharField(max_length=50)
-    otp = serializers.CharField(max_length=6)
-
-    def login_user(self, username, password, otp, context, **kwargs):
+    def login_user(self, username, password, context, **kwargs):
         user = authenticate(request=context.get('request'),
                             username=username, password=password)
         if not user:
@@ -87,16 +82,6 @@ class BasicAuthLoginProvider(LoginProvider):
             raise FormattedException(m='login_not_open', d={'reason': 'login_not_open'},
                                      status_code=HTTP_401_UNAUTHORIZED)
 
-        if user.totp_status == TOTPStatus.ENABLED:
-            if not otp or otp == '':
-                login_reject.send(sender=self.__class__, username=username, reason='no_2fa')
-                raise FormattedException(m='2fa_required', d={'reason': '2fa_required'},
-                                         status_code=HTTP_401_UNAUTHORIZED)
-            totp = pyotp.TOTP(user.totp_secret)
-            if not totp.verify(otp, valid_window=1):
-                login_reject.send(sender=self.__class__, username=username, reason='incorrect_2fa')
-                raise FormattedException(m='incorrect_2fa', d={'reason': 'incorrect_2fa'},
-                                         status_code=HTTP_401_UNAUTHORIZED)
         login.send(sender=self.__class__, user=user)
         return user
 

programming_language: Python
filename: 0004_auto_20200810_1111.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,44 @@
+# Generated by Django 3.0.5 on 2020-08-10 11:11
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import pyotp
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('authentication', '0003_passwordresettoken'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='passwordresettoken',
+            name='issued',
+            field=models.DateTimeField(auto_now_add=True),
+        ),
+        migrations.CreateModel(
+            name='TOTPDevice',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created', models.DateTimeField(auto_now_add=True)),
+                ('last_used', models.DateTimeField(null=True)),
+                ('totp_secret', models.CharField(default=pyotp.random_base32, max_length=16, null=True)),
+                ('verified', models.BooleanField(default=False)),
+                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='totp_device', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='BackupCode',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('code', models.CharField(max_length=8)),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='backup_codes', to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'unique_together': {('user', 'code')},
+            },
+        ),
+    ]

programming_language: Python
filename: models.py
change_type: ModificationType.MODIFY
diff: @@ -1,5 +1,6 @@
 from datetime import timedelta
 
+import pyotp
 from django.contrib.auth import get_user_model
 from django.db import models
 from django.utils import timezone
@@ -22,5 +23,33 @@ def one_day():
 class PasswordResetToken(models.Model):
     user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
     token = models.CharField(max_length=64)
-    issued = models.DateTimeField(default=timezone.now)
+    issued = models.DateTimeField(auto_now_add=True)
     expires = models.DateTimeField(default=one_day)
+
+
+class BackupCode(models.Model):
+    user = models.ForeignKey(get_user_model(), related_name='backup_codes', on_delete=models.CASCADE)
+    code = models.CharField(max_length=8)
+
+    class Meta:
+        unique_together = [
+            ('user', 'code')
+        ]
+
+    @staticmethod
+    def generate(user):
+        BackupCode.objects.filter(user=user).delete()
+        codes = [BackupCode(user=user, code=pyotp.random_base32(8)) for i in range(10)]
+        BackupCode.objects.bulk_create(codes)
+        return BackupCode.objects.filter(user=user).values_list('code', flat=True)
+
+
+class TOTPDevice(models.Model):
+    user = models.OneToOneField(get_user_model(), related_name='totp_device', on_delete=models.CASCADE)
+    created = models.DateTimeField(auto_now_add=True)
+    last_used = models.DateTimeField(null=True)
+    totp_secret = models.CharField(null=True, max_length=16, default=pyotp.random_base32)
+    verified = models.BooleanField(default=False)
+
+    def validate_token(self, token):
+        return pyotp.TOTP(self.totp_secret).verify(token, valid_window=1)

programming_language: Python
filename: serializers.py
change_type: ModificationType.MODIFY
diff: @@ -12,12 +12,21 @@ from plugins import providers
 class LoginSerializer(serializers.Serializer):
     username = serializers.CharField()
     password = serializers.CharField(trim_whitespace=False)
-    otp = serializers.CharField(max_length=6, allow_null=True, allow_blank=True)
 
     def validate(self, data):
         user = providers.get_provider('login').login_user(**data, context=self.context)
-        if user is not None:
-            data['user'] = user
+        data['user'] = user
+        return data
+
+
+class LoginTwoFactorSerializer(serializers.Serializer):
+    username = serializers.CharField()
+    password = serializers.CharField(trim_whitespace=False)
+    tfa = serializers.CharField(max_length=255, allow_null=True, allow_blank=True)
+
+    def validate(self, data):
+        user = providers.get_provider('login').login_user(**data, context=self.context)
+        data['user'] = user
         return data
 
 

programming_language: Python
filename: urls.py
change_type: ModificationType.MODIFY
diff: @@ -12,6 +12,7 @@ urlpatterns = [
     path('add_2fa/', views.AddTwoFactorView.as_view(), name='add-2fa'),
     path('verify_2fa/', views.VerifyTwoFactorView.as_view(), name='verify-2fa'),
     path('remove_2fa/', views.VerifyTwoFactorView.as_view(), name='remove-2fa'),
+    path('login_2fa/', views.LoginTwoFactorView.as_view(), name='login-2fa'),
     path('logout/', views.LogoutView.as_view(), name='logout'),
     path('request_password_reset/', views.RequestPasswordResetView.as_view(), name='request-password-reset'),
     path('password_reset/', views.DoPasswordResetView.as_view(), name='do-password-reset'),
@@ -19,5 +20,6 @@ urlpatterns = [
     path('resend_email/', views.ResendEmailView.as_view(), name='resend-email'),
     path('change_password/', views.ChangePasswordView.as_view(), name='change-password'),
     path('generate_invites/', views.GenerateInvitesView.as_view(), name='generate-invites'),
-    path('invites/', include(router.urls), name='invites')
+    path('invites/', include(router.urls), name='invites'),
+    path('regenerate_backup_codes', views.RegenerateBackupCodesView.as_view(), name='regenerate-backup-codes')
 ]

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -1,8 +1,7 @@
 import random
-import string
 import secrets
+import string
 
-import pyotp
 from django.contrib.auth import get_user_model
 from django.core.validators import EmailValidator
 from django.db import transaction
@@ -10,34 +9,31 @@ from django.utils.decorators import method_decorator
 from django.views.decorators.debug import sensitive_post_parameters
 from django_filters.rest_framework import DjangoFilterBackend
 from rest_framework import permissions
-from rest_framework.authtoken.views import ObtainAuthToken
 from rest_framework.generics import CreateAPIView, GenericAPIView, get_object_or_404
 from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_401_UNAUTHORIZED
 from rest_framework.views import APIView
 
 from authentication import serializers
+from authentication.models import InviteCode, PasswordResetToken, TOTPDevice, BackupCode
 from authentication.permissions import HasTwoFactor, VerifyingTwoFactor
 from authentication.serializers import RegistrationSerializer, EmailVerificationSerializer, ChangePasswordSerializer, \
     GenerateInvitesSerializer, InviteCodeSerializer, EmailSerializer
-from backend import renderers
 from backend.mail import send_email
 from backend.response import FormattedResponse
 from backend.signals import logout, add_2fa, verify_2fa, password_reset_start, password_reset_start_reject, \
     email_verified, change_password, password_reset, remove_2fa
 from backend.viewsets import AdminListModelViewSet
 from member.models import TOTPStatus
-from team.models import Team
-from authentication.models import InviteCode, PasswordResetToken
 from plugins import providers
+from team.models import Team
 
-hide_password = method_decorator(sensitive_post_parameters("password",))
+hide_password = method_decorator(sensitive_post_parameters("password", ))
 
 
 class LoginView(APIView):
     permission_classes = (~permissions.IsAuthenticated,)
     serializer_class = serializers.LoginSerializer
     throttle_scope = "login"
-    renderer_classes = (renderers.RACTFJSONRenderer,)
 
     @hide_password
     def dispatch(self, *args, **kwargs):
@@ -50,6 +46,9 @@ class LoginView(APIView):
         if user is None:
             return FormattedResponse(status=HTTP_401_UNAUTHORIZED, d={'reason': 'login_failed'}, m='login_failed')
 
+        if user.has_2fa():
+            return FormattedResponse(status=HTTP_401_UNAUTHORIZED, d={'reason': '2fa_required'}, m='2fa_required')
+
         token = providers.get_provider('token').issue_token(user)
         return FormattedResponse({'token': token})
 
@@ -79,12 +78,10 @@ class AddTwoFactorView(APIView):
     throttle_scope = "2fa"
 
     def post(self, request):
-        totp_secret = pyotp.random_base32()
-        request.user.totp_secret = totp_secret
-        request.user.totp_status = TOTPStatus.VERIFYING
-        request.user.save()
+        totp_device = TOTPDevice(user=request.user)
+        totp_device.save()
         add_2fa.send(sender=self.__class__, user=request.user)
-        return FormattedResponse({"totp_secret": totp_secret})
+        return FormattedResponse({"totp_secret": totp_device.totp_secret})
 
 
 class VerifyTwoFactorView(APIView):
@@ -92,13 +89,13 @@ class VerifyTwoFactorView(APIView):
     throttle_scope = "2fa"
 
     def post(self, request):
-        totp = pyotp.TOTP(request.user.totp_secret)
-        valid = totp.verify(request.data["otp"], valid_window=1)
-        if valid:
-            request.user.totp_status = TOTPStatus.ENABLED
-            request.user.save()
+        if request.user.totp_device is not None and request.user.totp_device.validate_token(request.data["otp"]):
+            request.user.totp_device.verified = True
+            request.user.totp_device.save()
+            backup_codes = BackupCode.generate(request.user)
             verify_2fa.send(sender=self.__class__, user=request.user)
-        return FormattedResponse({"valid": valid})
+            return FormattedResponse({"valid": True, "backup_codes": backup_codes})
+        return FormattedResponse({"valid": False})
 
 
 class RemoveTwoFactorView(APIView):
@@ -106,7 +103,7 @@ class RemoveTwoFactorView(APIView):
     throttle_scope = "2fa"
 
     def post(self, request):
-        request.user.totp_status = TOTPStatus.DISABLED
+        request.user.totp_device.delete()
         request.user.save()
         remove_2fa.send(sender=self.__class__, user=request.user)
         send_email(
@@ -117,6 +114,54 @@ class RemoveTwoFactorView(APIView):
         return FormattedResponse()
 
 
+class LoginTwoFactorView(APIView):
+    permission_classes = (~permissions.IsAuthenticated,)
+    serializer_class = serializers.LoginTwoFactorSerializer
+    throttle_scope = "login"
+
+    @hide_password
+    def dispatch(self, *args, **kwargs):
+        return super(LoginTwoFactorView, self).dispatch(*args, **kwargs)
+
+    def issue_token(self, user):
+        token = providers.get_provider('token').issue_token(user)
+        return FormattedResponse({'token': token})
+
+    def post(self, request, *args, **kwargs):
+        serializer = self.serializer_class(data=request.data, context={'request': request})
+        serializer.is_valid(raise_exception=True)
+        user = serializer.validated_data['user']
+        if user is None:
+            return FormattedResponse(status=HTTP_401_UNAUTHORIZED, d={'reason': 'login_failed'}, m='login_failed')
+
+        if user.totp_status != TOTPStatus.ENABLED:
+            return FormattedResponse(status=HTTP_401_UNAUTHORIZED, d={'reason': '2fa_not_enabled'}, m='2fa_not_enabled')
+
+        token = serializer.data['tfa']
+
+        if len(token) == 6:
+            for device in user.totp_devices:
+                if device.validate_token(token):
+                    return self.issue_token(user)
+        elif len(token) == 8:
+            for code in user.backup_codes:
+                if token == code.code:
+                    code.delete()
+                    return self.issue_token(user)
+
+        return self.issue_token(user)
+
+
+class RegenerateBackupCodesView(APIView):
+    permission_classes = (permissions.IsAuthenticated & HasTwoFactor,)
+    serializer_class = serializers.LoginTwoFactorSerializer
+    throttle_scope = "2fa"
+
+    def post(self, request, *args, **kwargs):
+        backup_codes = BackupCode.generate(request.user)
+        return FormattedResponse({"backup_codes": backup_codes})
+
+
 class RequestPasswordResetView(APIView):
     permission_classes = (~permissions.IsAuthenticated,)
     throttle_scope = "request_password_reset"
@@ -265,8 +310,8 @@ class GenerateInvitesView(APIView):
         if serializer.validated_data["auto_team"]:
             team = get_object_or_404(Team, id=serializer.validated_data["auto_team"])
         with transaction.atomic():
-            for i in range(active_codes, serializer.validated_data["amount"]+active_codes):
-                code = f"{''.join([random.choice(string.ascii_letters+string.digits) for _ in range(8)])}{hex(i)[2:]}"
+            for i in range(active_codes, serializer.validated_data["amount"] + active_codes):
+                code = f"{''.join([random.choice(string.ascii_letters + string.digits) for _ in range(8)])}{hex(i)[2:]}"
                 codes.append(code)
                 invite = InviteCode(code=code, max_uses=serializer.validated_data["max_uses"])
                 if serializer.validated_data["auto_team"]:

programming_language: Python
filename: 0004_auto_20200810_1111.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,26 @@
+# Generated by Django 3.0.5 on 2020-08-10 11:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('member', '0003_auto_20200808_1649'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='member',
+            name='totp_secret',
+        ),
+        migrations.RemoveField(
+            model_name='member',
+            name='totp_status',
+        ),
+        migrations.AddField(
+            model_name='member',
+            name='state_actor',
+            field=models.BooleanField(default=False),
+        ),
+    ]

programming_language: Python
filename: models.py
change_type: ModificationType.MODIFY
diff: @@ -1,3 +1,4 @@
+import random
 import secrets
 import time
 from enum import IntEnum
@@ -32,14 +33,10 @@ class Member(AbstractUser):
             "Required. 36 characters or fewer. Letters, digits and @/./+/-/_ only."
         ),
         validators=[username_validator],
-        error_messages={"unique": _("A user with that username already exists."),},
+        error_messages={"unique": _("A user with that username already exists.")},
     )
     email = models.EmailField(_("email address"), blank=True, unique=True)
-    totp_secret = models.CharField(null=True, max_length=16)
-    totp_status = models.IntegerField(
-        choices=[(status, status.value) for status in TOTPStatus],
-        default=TOTPStatus.DISABLED,
-    )
+    state_actor = models.BooleanField(default=False)
     is_visible = models.BooleanField(default=False)
     bio = models.TextField(blank=True, max_length=400)
     discord = models.CharField(blank=True, max_length=36)
@@ -70,11 +67,11 @@ class Member(AbstractUser):
         token, created = Token.objects.get_or_create(user=self)
         return token.key
 
-    def is_2fa_enabled(self):
-        return self.totp_status == TOTPStatus.ENABLED
+    def has_2fa(self):
+        return self.totp_device is not None and self.totp_device.verified
 
     def should_deny_admin(self):
-        return self.totp_status != TOTPStatus.ENABLED and config.get(
+        return not self.has_2fa() and config.get(
             "enable_force_admin_2fa"
         )
 

programming_language: Python
filename: serializers.py
change_type: ModificationType.MODIFY
diff: @@ -15,9 +15,9 @@ class MemberSerializer(IncorrectSolvesMixin, serializers.ModelSerializer):
 
     class Meta:
         model = get_user_model()
-        fields = ['id', 'username', 'is_staff', 'bio', 'discord', 'discordid', 'twitter', 'reddit', 'team',
-                  'points', 'is_visible', 'is_active', 'solves', 'team_name', 'leaderboard_points', 'date_joined',
-                  'incorrect_solves']
+        fields = ['id', 'username', 'is_staff', 'bio', 'discord', 'discordid', 'state_actor', 'twitter', 'reddit',
+                  'team', 'points', 'is_visible', 'is_active', 'solves', 'team_name', 'leaderboard_points',
+                  'date_joined', 'incorrect_solves']
 
 
 class ListMemberSerializer(serializers.ModelSerializer):
@@ -37,7 +37,7 @@ class AdminMemberSerializer(IncorrectSolvesMixin, serializers.ModelSerializer):
         model = get_user_model()
         fields = ['id', 'username', 'is_staff', 'bio', 'discord', 'discordid', 'twitter', 'reddit', 'team',
                   'points', 'is_visible', 'is_active', 'solves', 'team_name', 'email', 'email_verified',
-                  'leaderboard_points', 'date_joined', 'incorrect_solves']
+                  'leaderboard_points', 'date_joined', 'state_actor', 'incorrect_solves']
 
 
 class MinimalMemberSerializer(serializers.ModelSerializer):
@@ -46,7 +46,7 @@ class MinimalMemberSerializer(serializers.ModelSerializer):
     class Meta:
         model = get_user_model()
         fields = ['id', 'username', 'is_staff', 'bio', 'discord', 'discordid', 'twitter', 'reddit', 'team',
-                  'points', 'is_visible', 'is_active', 'team_name', 'leaderboard_points', 'date_joined']
+                  'points', 'is_visible', 'is_active', 'team_name', 'leaderboard_points', 'state_actor', 'date_joined']
 
 
 class SelfSerializer(IncorrectSolvesMixin, serializers.ModelSerializer):
@@ -56,14 +56,15 @@ class SelfSerializer(IncorrectSolvesMixin, serializers.ModelSerializer):
     team_name = serializers.ReadOnlyField(source='team.name')
     email = serializers.EmailField()
     incorrect_solves = serializers.SerializerMethodField()
+    has_2fa = serializers.BooleanField(source='has_2fa')
 
     class Meta:
         model = get_user_model()
         fields = ['id', 'username', 'is_staff', 'bio', 'discord', 'discordid', 'twitter', 'reddit', 'team', 'email',
-                  'totp_status', 'points', 'solves', 'team_name', 'leaderboard_points', 'date_joined',
+                  'has_2fa', 'points', 'solves', 'team_name', 'leaderboard_points', 'date_joined',
                   'incorrect_solves']
-        read_only_fields = ['id', 'is_staff', 'team', 'email', 'totp_status', 'points',
-                            'leaderboard_points', 'date_joined', 'incorrect_solves']
+        read_only_fields = ['id', 'is_staff', 'team', 'email', 'points', 'leaderboard_points', 'date_joined',
+                            'incorrect_solves']
 
     def validate_email(self, value):
         self.instance.password_reset_token = secrets.token_hex()

programming_language: Python