Back to Posts

Share this post

.NET Grey Box Approach: Source Code Review & Dynamic Analysis

Posted by: voidsec

Reading Time: 11 minutes

Following a recent engagement, I had the opportunity to check and verify some possible vulnerabilities on an ASP .NET application. Despite not being the deepest technical nor innovative blog post you could find on the net, I have decided to post it anyway in order to explain the methodology I adopt to verify possible vulnerabilities.

If you are into grey-box approach (Source Code Review and Dynamic Analysis, SAST/DAST), new to ASP .NET applications or you are planning to take AWAE, you could find it useful.

Contents

  • The Payload
    • HTTP Request / Response
    • Stack Trace
  • Verifying the Vulnerability
    • PoC Verification & Minimization
  • Root Cause Analysis

The Payload

While reviewing GPSGate, a real-time tracking and fleet management application, the following authenticated POST request (made by Acunetix) triggered an SQL error. As I had the application installed in my lab, instead of going blind, firing random payloads at the DBMS, I have opted for a more scientific and “static approach”.

HTTP Request

POST /GpsGateServer/SiteAdmin/SiteAdmin.aspx HTTP/1.1
Host: 192.168.137.1
Content-Length: 6119
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://192.168.137.1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://192.168.137.1/GpsGateServer/SiteAdmin/SiteAdmin.aspx
Accept-Encoding: gzip, deflate
Accept-Language: it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: FransonSessionID=F8F14298DB92A4D893D2A64C4F47B531; FransonApplicationID=B2D48E202D14ECE56CAF6A5FB5E362E4
Connection: close

__EVENTTARGET=&__EVENTARGUMENT=&__LASTFOCUS=&__VIEWSTATE=[--SNIP--]&__VIEWSTATEGENERATOR=A89BE48F&__SCROLLPOSITIONX=0&__SCROLLPOSITIONY=0&__VIEWSTATEENCRYPTED=&__EVENTVALIDATION=[--SNIP--]&Content__1ApplicationsManage__2ascx%24ApplicationSearch%24ImpersonateLogin%24hfUID=&Content__1ApplicationsManage__2ascx%24ApplicationSearch%24ImpersonateLogin%24hfAID=&Content__1ApplicationsManage__2ascx%24ApplicationSearch%24RadioButtonListSearch=0&Content__1ApplicationsManage__2ascx%24ApplicationSearch%24TextBoxSearch=1%00%C0%A7%C0%A2%252527%252522&Content__1ApplicationsManage__2ascx%24ApplicationSearch%24ButtonSearch=&Content__1ApplicationsManage__2ascx%24ApplicationSearch%24ddlPageSize=30&HiddenFieldSwitchState=&HiddenFieldSwithToControl=&HiddenFieldSwitchToPage=

The “vulnerable” parameter is: Content__1ApplicationsManage__2ascx%24ApplicationSearch%24TextBoxSearch
While the payload was: 1%00%C0%A7%C0%A2%252527%252522

HTTP Response

Stack Trace

[OdbcException (0x80131937): ERROR [42000] [MySQL][ODBC 8.0(w) Driver][mysqld-8.0.18]You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ''%%' at line 1]
    System.Data.Odbc.OdbcConnection.HandleError(OdbcHandle hrHandle, RetCode retcode) +1432589
    System.Data.Odbc.OdbcCommand.ExecuteReaderObject(CommandBehavior behavior, String method, Boolean needReader, Object[] methodArguments, SQL_API odbcApiMethod) +1294
    System.Data.Odbc.OdbcCommand.ExecuteReaderObject(CommandBehavior behavior, String method, Boolean needReader) +148
    System.Data.Odbc.OdbcCommand.ExecuteReader(CommandBehavior behavior) +96
    Franson.DAO.<Execute>d__12.MoveNext() +400
    System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw() +32
    Franson.DAO.DAOConnector.RetryConnect(Exception ex, Boolean bThrow) +251
    Franson.DAO.<Execute>d__12.MoveNext() +889
[ReaderDBException: Error executing reader]
    Franson.DAO.<Execute>d__12.MoveNext() +1068
    Franson.Directory.DAO.<ExecuteApplicationReaderMatch>d__12.MoveNext() +284
    System.Collections.Generic.List`1..ctor(IEnumerable`1 collection) +341
    GpsGate.SiteAdmin.WebUI.Controls.ApplicationSearchControl.QueryApplications(String strQuery) +449
    GpsGate.SiteAdmin.WebUI.Controls.ApplicationSearchControl.BindGridViewApplications() +32
    System.Web.UI.WebControls.Button.OnClick(EventArgs e) +11595696
    System.Web.UI.WebControls.Button.RaisePostBackEvent(String eventArgument) +274
    System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint) +1890

The stack trace is a great aid when debugging a program as it shows the complete trace-back of actions that lead to the error/exception.

As it is a view of a stack structure (LIFO, Last In – First Out) you should read it from bottom to top.

Note that the stack trace includes calls from the .Net system; you don’t normally need to follow Microsoft’s code (e.g. System.Web.UI.WebControls.Button.RaisePostBackEvent) to find out what went wrong but only the code that belongs to your application. The plus, followed by digits (e.g. +274), is the line of code responsible for that function call in the compiled binary.

Reconstructing the stack

Action # 3 (System.Web.UI.WebControls.Button.OnClick), as marked in the stack view, is triggered by pressing the “Search” functionality in the web application. The OnClick event will call the GpsGate.SiteAdmin.WebUI.Controls.ApplicationSearchControl.BindGridViewApplications that will then call GpsGate.SiteAdmin.WebUI.Controls.ApplicationSearchControl.QueryApplications passing a “strQuery” string parameter to the underlying DAO and so on, until the ODBC Connector will issue the query to the DBMS.

NB: important function calls within the Stack Trace (bottom-up view) that will lead to the SQL error at lines 4 & 5 are made by GPSGate and are red-marked while, lines 7,8,9,10,12 made by the DAO are orange-marked.

In case you were wondering, the DAO or Data Access Object is a class with methods that represents a tabular entity in an RDBMS. It is used to segment the data layer from the business logic. DAO’s methods are like class methods with queries that will be called by the business logic layer.

Verifying the Vulnerability

1.      PoC Verification

Reissue the request and verify to obtain the same response back from the application server.

2.      PoC Minimization

Start removing possible unnecessary parameters and headers, one by one (in a scientific approach), in order to minimize the PoC.

Minimized request:

POST /GpsGateServer/SiteAdmin/SiteAdmin.aspx HTTP/1.1
Host: 192.168.137.1
Content-Type: application/x-www-form-urlencoded
Referer: http://192.168.137.1/GpsGateServer/SiteAdmin/SiteAdmin.aspx
Cookie: FransonSessionID=362E739CD35D5E31124C6E34D66E3C1E; FransonApplicationID=B2D48E202D14ECE56CAF6A5FB5E362E4
Connection: close
Content-Length: 5605

__VIEWSTATE=[--SNIP--]&__VIEWSTATEENCRYPTED=&__EVENTVALIDATION=[--SNIP--]&Content__1ApplicationsManage__2ascx%24ApplicationSearch%24TextBoxSearch=1%00%C0%A7%C0%A2%252527%252522&Content__1ApplicationsManage__2ascx%24ApplicationSearch%24ButtonSearch=

Minimized PoC

After decoding multiple nested URL obfuscated vectors, we will remain with 1%00'", where %00 is the .NET null-byte equivalent. Upon further inspection, we will discover that only the NULL byte is needed to trigger the SQL error.

Root Cause Analysis

We should start by identifying the compiled file responsible for the application search logic, looking at the first useful Stack Trace:

GpsGate.SiteAdmin.WebUI.Controls.ApplicationSearchControl.BindGridViewApplications() +32

In the GPSGate IIS install folder (default: C:\GpsGateServer\IIS), we should search for a .DLL which name is GpsGate.SiteAdmin.WebUI.

We can find: C:\GpsGateServer\IIS\bin\GpsGate.SiteAdmin.WebUI.dll

Which is a .NET compiled library (DLL)

We then try to decompile the application logic file with ILSpy as it is a .NET assembly. Fortunately, in this case, it is not obfuscated.

We then load the appropriate namespace: GpsGate.SiteAdmin.WebUI.Controls, load the appropriate class: GpsGate.SiteAdmin.WebUI.Controls.ApplicationSearchControl

And the method BindGridViewApplications. Now we need to understand the application code flow.

protected void BindGridViewApplications()
{
  List<ApplicationBag> list = QueryApplications(Query); 
  if (btnNewApp != null)
  {
    bool flag = RadioButtonListSearch.SelectedIndex == 0;
    bool flag2 = Franson.Directory.Session.CurrentSession.CheckApplicationPrivilege("_CreateApplication");
    btnNewApp.Visible = ((flag2 && flag) ? true : false);
  }
  if (list.Count > 0)
  {
    GridViewApplications.PageSize = (ddlPageSize.SelectedValue.Equals("All") ? list.Count : Convert.ToInt32(ddlPageSize.SelectedValue));
  }
  list = list.FindAll((ApplicationBag a) => ApplicationTagManager.CheckTaggedApplicationPrivilege("_ReadApplication", a.ID, a.BOType));
  GridViewApplications.DataSource = list;
  GridViewApplications.DataBind();
}

At line 3 QueryApplications function is called passing the Query parameter; the Query parameter is what is containing the user input and in our case the payload.

Following the QueryApplications function, lead us to the following code:

protected List<ApplicationBag> QueryApplications(string strQuery)
{
  if (m_ApplicationSource == null)
  {
    bool bTemplates = RadioButtonListSearch.SelectedValue == "1";
    string text = strQuery + "_" + bTemplates;
    if (QueryCache.Key == text)
    {
      m_ApplicationSource = QueryCache.Value;
    }
    else
    {
      ApplicationReader applicationReader = new ApplicationReader();
      if (strQuery == "%")
      {
        m_ApplicationSource = new List<ApplicationBag>(applicationReader.GetAll(0, -1));
      }
      else
      {
        m_ApplicationSource = new List<ApplicationBag>(applicationReader.QueryWithMatch(strQuery, bTemplates, 0, -1));
      }
      QueryCache = new KeyValuePair<string, List<ApplicationBag>>(text, m_ApplicationSource);
    }
    string strDir = "DESC";
    string strExp = "name";
    if (ViewState["ApplicationGridSort"] != null)
    {
      strExp = ViewState["ApplicationGridSort"].ToString();
      if (ViewState["ApplicationGridSortDir"] != null)
      {
        strDir = ViewState["ApplicationGridSortDir"].ToString();
      }
    }
    m_ApplicationSource = m_SortSource(m_ApplicationSource, strExp, strDir);
  }
  return m_ApplicationSource;
}

Here, if the input being searched has been already queried in the past, we will get the results back from the QueryCache (lines 7 and 9), otherwise, at line 20 QueryWithMatch is called.

Following QueryWithMatch we can understand that it is just a wrapper for DAO.QueryWithMatch.

public virtual IEnumerable<ApplicationBag> QueryWithMatch(string strQuery, bool bTemplates, int iIndex, int iCount)
{
  return DAO.QueryWithMatch(strQuery, bTemplates, iIndex, iCount); 
}

QueryWithMatch is where we finally land in the part of code where the SQL query string is constructed.

public virtual IEnumerable<ApplicationBag> QueryWithMatch(string strQuery, bool bTemplates, int iIndex, int iCount)
{
  string text = DAOReaderBase.EscapeString(strQuery); 
  m_strSQL = "SELECT q1.*, CASE WHEN q2.user_count IS NOT NULL THEN q2.user_count ELSE 0 END AS user_count, CASE WHEN q3.user_count IS NOT NULL THEN q3.user_count ELSE 0 END AS lic_user_count, CASE   WHEN q1.name LIKE '" + text + "' THEN q1.name   WHEN q1.description LIKE '" + text + "' THEN q1.description   ELSE NULL END AS match_app, CASE   WHEN u.name LIKE '" + text + "' THEN u.name   WHEN u.username LIKE '" + text + "' THEN u.username   ELSE NULL END AS match_user, CASE   WHEN d.imei LIKE '" + text + "' THEN d.imei   WHEN d.email LIKE '" + text + "' THEN d.email   WHEN d.phone_number LIKE '" + text + "' THEN d.phone_number   ELSE NULL END AS match_dev FROM applications q1 LEFT JOIN (   SELECT a.application_id, COUNT(DISTINCT tu.user_id) as user_count   FROM applications a   LEFT JOIN tag t ON t.application_id = a.application_id   LEFT JOIN tag_users tu ON tu.tag_id = t.tag_id   LEFT JOIN users u ON u.user_id = tu.user_id   WHERE a.deleted <> 1 AND u.active = 1   GROUP BY a.application_id ) AS q2 ON q2.application_id = q1.application_id LEFT JOIN (   SELECT a.application_id, COUNT(DISTINCT tu.user_id) as user_count   FROM applications a   LEFT JOIN tag t ON t.application_id = a.application_id AND t.tag_name <> '_Master'   LEFT JOIN tag_users tu ON tu.tag_id = t.tag_id   LEFT JOIN users u ON u.user_id = tu.user_id   LEFT JOIN tag_privileges tp ON tp.tag_id = tu.tag_id   LEFT JOIN privilege p ON p.privilege_id = tp.privilege_id AND p.name = '_DeviceLogin'   WHERE a.deleted <> 1 AND p.name IS NOT NULL AND u.active = 1   GROUP BY a.application_id ) AS q3 ON q2.application_id = q3.application_id JOIN " + DAOBase.EscapeDbObjectName("groups") + " g ON g.application_id = q1.application_id LEFT JOIN user_groups ug ON ug.group_id = g.group_id LEFT JOIN users u ON u.user_id = ug.user_id LEFT JOIN device d ON d.owner_id = u.user_id LEFT JOIN template t ON t.object_id = q1.application_id AND t.object_type LIKE '%Application' WHERE q1.deleted <> 1 AND (   q1.name LIKE '" + text + "'   OR q1.description LIKE '" + text + "'   OR u.name LIKE '" + text + "'   OR u.username LIKE '" + text + "'   OR d.IMEI LIKE '" + text + "'   OR d.email LIKE '" + text + "'   OR d.phone_number LIKE '" + text + "' ) " + (bTemplates ? "AND (t.template_id IS NOT NULL AND t.readonly = 1 AND t.partial = 0 AND t.original = 1)" : "AND (   t.template_id IS NULL   OR (t.readonly = 0 AND t.partial = 0) )");
  return ExecuteApplicationReaderMatch(iIndex, iCount);
}

Two things to note here:

  1. No prepared statements have been used, our input is directly concatenated to the query string, rendering the query vulnerable to SQL Injection attacks (line 4).
  2. However, before the input is concatenated, the DAOReaderBase.EscapeString function is called.

Going through multiple wrappers, we finally land into EscapeString:

// Franson.DAO.DAOBase
public static string EscapeString(string strValue, string DBType)
{
  if (strValue == null)
  {
    throw new ArgumentNullException("Value cannot be null in call to EscapeString(). Paramenter strValue");
  }
  strValue = strValue.Replace("'", "''");
  if (DBType == "MySQL")
  {
    strValue = strValue.Replace("\\", "\\\\");
  }
  return strValue;
}

Our input goes through the EscapeString function that will replace any single quote with two of them (line 8) and, in case of a MySQL DBMS, any backslash with two of them (line 11); before finally concatenating our input to the original query.

As a reference, the Replace method returns a new string in which all occurrences of a specified Unicode character or String in the current string are replaced with another specified Unicode character or String.

Is it vulnerable?

Unfortunately, it is NOT vulnerable as any payload trying to break through the original query (e.g. ‘or 1=1 -- -), will be escaped with couple more single quotes.

SELECT q1.*, CASE WHEN q2.user_count IS NOT NULL THEN q2.user_count ELSE 0 END AS user_count, CASE WHEN q3.user_count IS NOT NULL THEN q3.user_count ELSE 0 END AS lic_user_count, CASE   WHEN q1.name LIKE '''or 1=1-- -' THEN q1.name   WHEN q1.description LIKE '[--SNIP--]

The first quote is from the original query, the following two are being introduced by the EscapeString method and the last one is again from the original query; thus, rendering our payload non-working as it does not breaks the original query. Without escaping the single quote, we cannot effectively inject our SQL commands and modify the resulting query.

Ok then, why are we getting back the SQL error message?

Good question, we are getting back the SQL message due to the %00, the .NET equivalent of the NULL Byte. The .NET Common Language Runtime (CLR) considers NULL bytes as ‘data’, not null and .NET strings are not NULL byte terminated.

Look at the following example:

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            String data = "\0";
            if (data is null) {
                Console.WriteLine("Data is null");\
            }
            else { 
                Console.WriteLine("Data is NOT null");
            }
            using (System.IO.StreamWriter file = new System.IO.StreamWriter(@"C:\Users\voidsec\Desktop\WriteLines.txt", true))
            {
                file.WriteLine("Writing a line with a "+data+" byte");
            }
        }
    }
}

However, native POSIX compliant function calls terminate all strings at the first found NULL byte. This interoperability issue is encountered whenever the data containing a NULL byte is used by .NET to directly call a native POSIX compliant function call, or as in this case, concatenating our input to a (not very well) escaped SQL query; prematurely terminating it and causing the error.

Back to Posts