Common CWE Finds: Reachable Assertion

Published July 25, 2023 for Mayhem

In this blog post series, we’re diving into Mayhem’s top common weaknesses enumeration (CWE) finds. A Common Weakness Enumeration, or CWE for short, is a list of software and hardware patterns that can lead to vulnerabilities and other weaknesses. One such CWE that is both fairly common and possibly unexpected is the reachable assertion, a fascinatingly niche weakness that can lead to denial of service attacks.

How Do Assertions Work, Anyway?

In their simplest form, assertions are simply conditionals that, when false, immediately halt the execution of the underlying program. While not a feature of every major language, assertions are still quite common and are typically used as pre-conditions to ensure the necessary environment and dependencies are available to the running program.

As an example, let’s say that we have an application that can only run successfully before the year 2032. The way we might enforce this pre-condition using an assert in the C programming language might look like this:

#include <assert.h>
#include <stdio.h>
#include <time.h>

int main()
{
  time_t t = time(NULL);
  struct tm tm = *localtime(&t);

    assert(tm.tm_year + 1900 < 2032);

  return 0;
}

At the time of writing, this program would simply return successfully; however from January 1, 2032 and on, it would immediately abort with the following error messaging:

example: example.c:10: main: Assertion `tm.tm_year + 1900 < 2032' failed.
Aborted

When Good Assertions Go Bad

Unfortunately, not all assertions are created equally. When misused or misunderstood, they can introduce exploitable vulnerabilities into your codebase, ultimately degrading performance and breeding instability. This type of vulnerable condition is called a “reachable assertion.”

The Reachable Assertion

A reachable assertion — also known as CWE-617 for all you security nerds out there — is a vulnerability where an assertion can be explicitly triggered due to user input, causing the underlying application to abort at an unexpected and potentially unsafe time. This can be demonstrated in the following (very simplistic) example, which uses an assertion to validate user input:

#include <assert.h>
#include <stdio>

int main () {
   int a;

   printf("How old are you? ");
   scanf("%d", &a);
   assert(a < 100);

   return 0;
}

As you can see, whenever the user inputs an age that is older than 99, the program execution is unceremoniously aborted:

How old are you? 110
example: example.c:9: main: Assertion `a < 100' failed.
Aborted

Service Denied

Okay, so… what’s the big deal, right?

So what if the user can crash the program by inserting bad data?

While not necessarily a bad thing on its face, a reachable assertion can become particularly risky in the event of applications that handle simultaneous connections, such as socket servers. If a bad actor is able to trigger an assertion in an application such as this, it would abort the entire program, disconnecting all active connections and resulting in a denial of service.

IRL Reachable Assertions

It’s important to note that this isn’t just theoretical. Reachable assertions have had a very real impact on the stability of many applications and services. A particularly notable one was found in 2021 in the popular NoSQL database server, MongoDB.

Classified as CVE-2021-32037, this vulnerability was described as a situation wherein “an authorized user may trigger an invariant which may result in denial of service or server exit if a relevant aggregation request is sent to a shard. Usually, the requests are sent via mongos and special privileges are required in order to know the address of the shards and to log in to the shards of an auth enabled environment.” This required a security update to the underlying MongoDB server for mitigation.

Assertive Alternatives

While it can be difficult to protect against all potential vulnerabilities, the best defense when writing secure software is to try and be mindful of the complexities of the coding paradigms and patterns you choose to use, and to always have a documented approach to handling common scenarios. In the case of assertions, they are not always the most appropriate way to validate and respond to application conditions.

A good rule of thumb with assertions is that you should never use them in a production environment if the underlying condition is recoverable. This is commonly handled through the use of exceptions (however, this isn’t the only acceptable alternative).

Rather than asserting that a condition is true, you can instead throw an exception (or simply handle the improper condition without throwing any errors at all), which can give your application an opportunity to adapt to the circumstances and recover from it.

To fully demonstrate the difference between these two approaches, let’s take a look at a C++ program that takes two numbers and divides them while asserting that the denominator is not zero:

#include <iostream>
#include <cassert>

using namespace std;

double division(int a, int b)
{
  assert( b != 0 );
  return a / (double) b;
}

int main ()
{
  int x, y;
  double z = 0;

  cout << "Numerator: ";
  cin >> x;

  cout << "Denominator: ";
  cin >> y;

	z = division(x, y);
  cout << z << endl;

  return 0;
}

‍ As expected, when the denominator is set to zero, the program aborts:

Numerator: 10
Denominator: 0
example: example.cpp:7: double division(int, int): Assertion `b != 0' failed.
Aborted

However, if we were to rewrite it using more carefully considered error handling, we could ensure that the underlying program continues to run while allowing the user to correct their mistake:

#include <iostream>
#include <cassert>

using namespace std;

double division(int a, int b)
{
  if ( b == 0 ) {
    throw "Division by zero!";
  }

  return a / (double) b;
}

int main ()
{
  int x, y;
  double z = 0;

  do {
	  cout << "Numerator: ";
	  cin >> x;

	  cout << "Denominator: ";
	  cin >> y;

	  try {
		  z = division(x, y);
	    cout << z << endl;
      break;
	  } catch ( const char* msg ) {
	    cerr << msg << endl;
	  }
  } while ( true );

  return 0;
}

‍ Now, instead of unceremoniously aborting due to some bad input, we instead prompt the user to re-enter their numbers until they input valid data.

While this is an incredibly simplistic example, it demonstrates the importance of well-thought-out error handling and control flow within your application. Over-leveraging improper tools like assertions in the wrong areas can not only lead to vulnerabilities in more complex implementations but also degrade the user experience.

Know When to Quit

Assertions, while potentially risky in the wrong hands, do accomplish one thing well: they stop the program execution. This might be a desired outcome in many circumstances; however, I am a big believer in deliberate, manageable actions within a codebase.

If you want to check a condition and then halt execution as a result of that condition, it is always better to separate those two actions and build out a robust solution that gives you plenty of opportunity to do things like safely close connections and gracefully exit. Just like life, sometimes knowing when to quit is the most important skill you can have.