How do you protect something that must be exposed?

How do you protect something that must be exposed?

How do you protect something that must be exposed? Say you have a sensitive piece of code everyone can run, that you cannot hide. Do you leave it naked and alone, weathering the digital storms of our modern age, or do you buckle down and write some protection? The following is a tale of the latter.

A customer I was working for had what I thought was a rather common issue: A 'loyalty card' registration page, where you begin the process by entering the card number, and, if the card was not previously registered, the user would be taken to a registration form. If it was taken, they'd get an error message. As an occasional dabbler in internet security, this set of warning bells in my head right of the bat: an un-authorized user could simply set up a brute force attack, sending in iterative/random numbers, and based on the servers response, put the card in either a 'Unregistered' or 'Registered' list. The attacker could then simply register the unregistered cards to his own account, or, more profitably, demand a refund.

A tester on my team set up such an attack, using a load-testing framework. And lo and behold, a list of a few hundred unregistered cards could be siphoned in a matter of minutes. Of lesser importance but still cause for concern was the fact that this taxed the server, so that other users would get a noticeable delay in response while the attack was happening.

My immediate thought was to hide the check behind authorization: dealing with a user account is far simpler than a random machine on the internet, and most anti-brute force guides are based on account control. Alas, our registration process starts with entering a card number, and changing this process was out of the question, as the process relied on external systems which we could not change, and which required things to be done in a certain order.

After a brief dalliance with the idea of using captchas (too complex for some of our users) or cookies (an attacker could delete the cookie), I settled on IP: the first request should go as normal, subsequent requests should take 5 seconds longer than the previous one. In this way, you don't end up hard-blocking legitimate multiple requests from the same IP (say, an office building with shared IP), while at the same time making a large brute force attack unfeasible.


We start off with a dictionary and set values for the delay per attempt and time before a reset:

private Dictionary<string, DateTime> antiBruteForceIpList = new Dictionary<string, DateTime>();

private static readonly int RetentionTimeInSeconds = 900;
private static readonly int DelayInSecondsPerAttemptWithinRetentionTime = 5;

The string is the IP requesting a card-check, the DateTime the time of the request. Second, we need a way to tell how many times the IP has made a request. As I'm rather fond of Tuples, I tried one for a short while, before realizing, as so often before, that Item1 and Item2 are not all that descriptive names, and defaulting to using a model:

     private class BruteForceEntry
     {
         public DateTime TimeOfLastAttempt = DateTime.Now;

         public int NumberOfAttemptsMinusOne = 0;
     }

Subsequently we must update our dictionary to use the new model:

     //string is IP
     private Dictionary<string, BruteForceEntry> antiBruteForceIpList = new Dictionary<string, BruteForceEntry>();

Next, we need a method of adding Ips to the dictionary and getting the delay:

     public TimeSpan GetDelayForIp(string ip)
     {
         var newEntry = new BruteForceEntry();
         antiBruteForceIpList.Add(ip, newEntry);
         return TimeSpan.FromSeconds(0);
     }

Then, we need some way to tell how many attempts have been made from that same IP. Since this is in the domain of the BruteForceEntry, we create a AddAttempt function to it:

     private class BruteForceEntry
     {
         public DateTime TimeOfLastAttempt = DateTime.Now;

         public int NumberOfAttemptsMinusOne = 0;

         public void AddAttempt()
         {
             NumberOfAttemptsMinusOne++;
             TimeOfLastAttempt = DateTime.Now;
         }
     }


Before returning to GetDelayForIp to use the new function:

     public TimeSpan GetDelayForIp(string ip)
     {
         if (antiBruteForceIpList.ContainsKey(ip))
         {
             antiBruteForceIpList[ip].AddAttempt();
             return TimeSpan.FromSeconds(antiBruteForceIpList[ip].NumberOfAttemptsMinusOne *
                                                               DelayInSecondsPerAttemptWithinRetentionTime);
         }
         var newEntry = new BruteForceEntry();
         antiBruteForceIpList.Add(ip, newEntry);
         return TimeSpan.FromSeconds(0);
     }


We now have a working 'give me the delay for this IP' function, the only drawbacks being that you remain on the 'naughty list' indefinitely, unless someone messes up the instantiation, and thread safety. Let's solve the indefinite part first:

     private void ClearOldEntries()
     {
         foreach (var ipAndTime in antiBruteForceIpList)
         {
             if (ipAndTime.Value.TimeOfLastAttempt.AddSeconds(RetentionTimeInSeconds) < DateTime.Now)
             {
                 antiBruteForceIpList.Remove(ipAndTime.Key);
             }
         }
     }


As this is a seldom used page, I do not want to assign scheduling. We therefore run the above code before each check:

     public TimeSpan GetDelayForIp(string ip)
     {
         ClearOldEntries();
         if (antiBruteForceIpList.ContainsKey(ip))
         {
             antiBruteForceIpList[ip].AddAttempt();
             return TimeSpan.FromSeconds(antiBruteForceIpList[ip].NumberOfAttemptsMinusOne * DelayInSecondsPerAttemptWithinRetentionTime);
         }
         var newEntry = new BruteForceEntry();
         antiBruteForceIpList.Add(ip, newEntry);
         return TimeSpan.FromSeconds(0);
     }

The last of the three - thread safety - we ensure by implementing a lock around the entire GetDelayForIp:

     private Object aLock = new Object();

     public TimeSpan GetDelayForIp(string ip)
     {
         lock (aLock)
         {
             ClearOldEntries();
             if (antiBruteForceIpList.ContainsKey(ip))
             {
                 antiBruteForceIpList[ip].AddAttempt();
                 return TimeSpan.FromSeconds(antiBruteForceIpList[ip].NumberOfAttemptsMinusOne *
                                                          DelayInSecondsPerAttemptWithinRetentionTime);
             }

             var newEntry = new BruteForceEntry();
             antiBruteForceIpList.Add(ip, newEntry);
             return TimeSpan.FromSeconds(0);
         }
     }

Finally, we introduce a singleton pattern so that instantiation problems are minimized, and wrap the whole thing in a class. If the architecture allows, you should skip the manual singleton pattern and instead use the IoC container to configure it as a singleton. You should end up with something like this:

     public class AntiBruteForceIPManager
     {
         //string is IP
         private Dictionary<string, BruteForceEntry> antiBruteForceIpList = new Dictionary<string, BruteForceEntry>();

         private static readonly int RetentionTimeInSeconds = 900;
         private static readonly int DelayInSecondsPerAttemptWithinRetentionTime = 5;



         private static readonly Lazy<AntiBruteForceIPManager> lazy =
             new Lazy<AntiBruteForceIPManager>(() => new AntiBruteForceIPManager());

         public static AntiBruteForceIPManager Instance { get { return lazy.Value; } }

         private AntiBruteForceIPManager()
         {
         }


         public TimeSpan GetDelayForIp(string ip)
         {
             lock (aLock)
             {
                 ClearOldEntries();
                 if (antiBruteForceIpList.ContainsKey(ip))
                 {
                     antiBruteForceIpList[ip].AddAttempt();
                     return TimeSpan.FromSeconds(antiBruteForceIpList[ip].NumberOfAttemptsMinusOne *
                                                                DelayInSecondsPerAttemptWithinRetentionTime);
                 }

                 var newEntry = new BruteForceEntry();
                 antiBruteForceIpList.Add(ip, newEntry);
                 return TimeSpan.FromSeconds(0);
             }
         }

         private void ClearOldEntries()
         {
             foreach (var ipAndTime in antiBruteForceIpList)
             {
                 if (ipAndTime.Value.TimeOfLastAttempt.AddSeconds(RetentionTimeInSeconds) < DateTime.Now)
                 {
                     antiBruteForceIpList.Remove(ipAndTime.Key);
                 }
             }
         }

         private class BruteForceEntry
         {
             public DateTime TimeOfLastAttempt = DateTime.Now;

             public int NumberOfAttemptsMinusOne = 0;

             public void AddAttempt()
             {
                 NumberOfAttemptsMinusOne++;
                 TimeOfLastAttempt = DateTime.Now;
             }
         }
     }

Stats:
Pre-upgrade:
1000 checks in 50 sec
5 sec response for other users during an attack
Post-upgrade:
30 checks in 330 sec (requests eventually time out)
300ms response for other users during an attack


Happy coding!

 

Om bloggeren:
Caspar Høegh er Tech Lead i eCommerce- avdelingen. Han har en bachelorgrad i IT og Informasjonssystemer fra Høgskolen i Buskerud, og har bred interesse for faget som har ført til posisjoner som team lead, fullstack-utvikler, tech lead, testleder og arkitekt. Noen ganger samtidig.

comments powered by Disqus