前言
馬上就要過春節了,本想著完成手頭的任務就可以準備過年了。沒想到Netty伺服器又被攻擊了,當收到伺服器報警(CPU飆升報警)資訊,就知道對方又下手了。
之前是交給下面的兄弟來解決,這次為了過個好年,決定親自動手把這事給了結了。
故事前奏Netty服務是公司比較邊緣的服務,只有一臺裝置在使用,而且程式碼是之前技術Leader(已離職)寫的,加上一直趕工期,所以就沒抽出時間去徹底解決這事。
當初被攻擊沒排查程式碼,看到遭到瘋狂請求、CPU跑滿、日誌打滿,還以為是遭遇DDoS攻擊了。
臨時採取了幾個措施:
分離伺服器,確保該服務遭到攻擊時不會拖垮其他服務;換了一個IP和埠;針對攻擊的IP新增黑名單;在程式碼層,發現非法請求強制關閉連線;新增日誌資訊,追溯攻擊報文和源頭;對攻擊服務的IP(上海阿里雲的)進行舉報;但沒多久,駭客又找上門來了,十天半月來一次攻擊,好像知道服務IP和後臺程式碼似的,陰魂不散。
這不,今天被逮到了,而且之前添加了日誌列印,也拿到了攻擊的報文內容,復現了攻擊操作。
// 攻擊者第一次嘗試的報文8000002872FE1D130000000000000002000186A00001977C0000000000000000000000000000000000000000// 攻擊者第二次嘗試的報文8000002872FE1D130000000000000002000186A00001977C00000000000000000000000000000000
上述報文,第一次的報文觸發了攻擊,第二次的報文沒有影響(與正常業務報文格式無異)。
下面就帶大家分析分析攻擊的邏輯和程式碼中存在的漏洞。
知識儲備要了解攻擊的原理,我們需要有一定的Netty技術知識。關於Netty如何實現客戶端和伺服器端的程式碼這裡就不展開了,可以看一下實現例項:https://github.com/secbr/netty-all/tree/main/netty-decoder
我們重點了解一下自定義解碼器和io.netty.buffer.ByteBuf。其中自定義解碼器用於對報文進行解析,而報文內容透過ByteBuf進行快取傳輸。
上面的攻擊報文格式表明,駭客已經“猜到”我們是基於16進位制Btye格式進行內容傳輸的(駭客竟然也知道)。
自定義解碼器要自定義解碼器,繼承MessageToMessageDecoder類並實現decode方法即可,下面展示一下示例程式碼:
public class MyDecoder extends MessageToMessageDecoder<ByteBuf> { @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { }}
其中解析報文的邏輯便是在decode方法內進行處理。其中ByteBuf in就是接收傳入報文的容器,而List<Object> out用於輸出解析之後的結果。
下面來看一下有bug的程式碼(已經過脫敏處理):
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { int readableBytes = in.readableBytes(); while (readableBytes > 3) { in.skipBytes(2); int pkgLength = in.readUnsignedShort(); in.readerIndex(in.readerIndex() - 4); if (in.readableBytes() < pkgLength) { return; } out.add(in.readBytes(pkgLength)); readableBytes = in.readableBytes(); }}
上面的程式碼在跑正常業務時是沒問題的,但當被攻擊時,就進入了死迴圈。因此,導致雖然在業務處理時添加了關閉連線的操作也是無效的。
在分析上面程式碼之前,我們還得先詳細分析一下ByteBuf的原理。
ByteBuf的原理ByteBuf中會維護兩個索引:一個索引(readIndex)用於讀取,一個索引(writeIndex)用於寫入。
當從ByteBuf讀取時,readIndex會被遞增已經被讀取的位元組數,當向ByteBuf中寫入資料時,writeIndex也會被遞增。
上面圖以攻擊的報文為例進行展示,攻擊者用了44個位元組的報文進行攻擊。由於使用的是16進位制,所以兩個字元佔用1個位元組。
readIndex和writeIndex的起始位置的索引位置都為0,當執行ByteBuf中的readXXX或writeXXX方法時,會推進對應的索引。當執行setXXX或getXXX方法的操作時則不會。
瞭解了ByteBuf的基本處理原理之後,我們就來對照攻擊者的報文和原始碼來進行攻擊過程的還原。
攻擊還原下面直接透過原始碼一步步的分析,主要涉及ByteBuf類的方法。有效攻擊的報文為上面提到的第一個報文。
// 攻擊者第一次嘗試的報文8000002872FE1D130000000000000002000186A00001977C0000000000000000000000000000000000000000
下面來看程式碼:
int readableBytes = in.readableBytes();
這行程式碼透過readableBytes方法獲取到當前ByteBuf中可以讀到的位元組數,上述攻擊報文88個字元,所以這裡得到44個位元組。
當readableBytes大於3時便進行具體的解析處理:
in.skipBytes(2);
很明顯,透過skipBytes方法跳過了兩個位元組。
int pkgLength = in.readUnsignedShort();
透過readUnsignedShort方法,獲得了2個位元組的內容,這兩個位元組對應的十六進位制值為“0028”,對應十進位制為“40”。這兩個位元組在報文中的含義是(部分或整個)報文的長度。
報文的長度往往有兩種演算法:第一,長度代表整個報文的長度(業務中使用的含義);第二,長度代表除前4個位元組之後的報文長度(攻擊者使用的含義)。
其實,正是因為這個長度含義的定義,導致正常業務可以執行,而攻擊報文會進入死迴圈。
下面繼續分享程式碼:
in.readerIndex(in.readerIndex() - 4);
經上面的skipBytes和readUnsignedShort的呼叫,ByteBuf的讀索引已經跑到了第4個位元組上了。所以這裡in.readerIndex()返回的值為4,而in.readerIndex(4-4)的作用就是將讀索引重置為0,也就是從頭開始讀。
if (in.readableBytes() < pkgLength) { return;}
這個判斷是在讀索引移動到0之後,看看報文的可讀位元組數是否小於報文內容中指定的位元組數。很顯然,in.readableBytes()對應的值為44個位元組,而pkgLength為40個位元組,不會進行return。
out.add(in.readBytes(pkgLength));
讀取40個位元組,進行輸出。還剩下4個位元組的內容,readIndex指向第40個位元組的位置。
readableBytes = in.readableBytes();
由於readIndex已經指向第40個位元組,所以此時可讀位元組數為4。
然後,進入第二輪迴圈。此時,神奇的情況就出現了。我們可以看到攻擊的後4個位元組的報文值全為0。
in.skipBytes(2);int pkgLength = in.readUnsignedShort();
因此跳過2個位元組後,readIndex為42,pkgLength獲取第43和44位元組的值:0。
in.readerIndex(in.readerIndex() - 4);
上述程式碼又將readIndex設定到第40個位元組。
if (in.readableBytes() < pkgLength) { return;}
此時會發現readableBytes返回值為4,但pkgLength已經變為0了,不會return。
接下讀取內容時就出現狀況了:
out.add(in.readBytes(pkgLength));// 這裡還剩下4個位元組readableBytes = in.readableBytes();
上述readBytes讀取位元組數為0,而readableBytes始終為4。此時,整個while迴圈進入了死迴圈,大量消耗CPU資源。
此時還沒完,最多隻是把CPU跑到100%,但是當不停的將空字元寫到接收資料的緩衝區域之後,緩衝區開始瘋狂呼叫處理業務的Handler,進一步侵入到業務處理邏輯當中。
雖然業務邏輯層做了判斷,也進行了連線的關閉,但此時已經與連線無關,while迴圈已經進入死迴圈,關掉連線也沒什麼作用。同時,業務層有日誌輸出,大量的日誌輸出到磁碟當中,導致磁碟被刷滿。
最終導致伺服器的CPU監控和磁碟監控報警。乍一看,還以為是又一次DDoS攻擊……
小結總結一下,其實就是攻擊者傳輸的報文長度和報文內指定的長度不一致,導致瞭解析報文時進入了死迴圈。
問題一旦發現,解決起來就很容易了。其實透過這件事也得到一些啟發。第一,遇到問題,迎難而上解決掉它,往往是最好的方案,逃避只能將問題往後拖,但並不能解決掉。第二,只要靜下心來分析,一步步分析,很少有解決不掉的問題。