在Netty服務(wù)被N次攻擊之后,終于抓到現(xiàn)行了!
前言
馬上就要過(guò)春節(jié)了,本想著完成手頭的任務(wù)就可以準(zhǔn)備過(guò)年了。沒(méi)想到Netty服務(wù)器又被攻擊了,當(dāng)收到服務(wù)器報(bào)警(CPU飆升報(bào)警)信息,就知道對(duì)方又下手了。
之前是交給下面的兄弟來(lái)解決,這次為了過(guò)個(gè)好年,決定親自動(dòng)手把這事給了結(jié)了。
故事前奏
Netty服務(wù)是公司比較邊緣的服務(wù),只有一臺(tái)設(shè)備在使用,而且代碼是之前技術(shù)Leader(已離職)寫的,加上一直趕工期,所以就沒(méi)抽出時(shí)間去徹底解決這事。
當(dāng)初被攻擊沒(méi)排查代碼,看到遭到瘋狂請(qǐng)求、CPU跑滿、日志打滿,還以為是遭遇DDoS攻擊了。
臨時(shí)采取了幾個(gè)措施:
分離服務(wù)器,確保該服務(wù)遭到攻擊時(shí)不會(huì)拖垮其他服務(wù); 換了一個(gè)IP和端口; 針對(duì)攻擊的IP添加黑名單; 在代碼層,發(fā)現(xiàn)非法請(qǐng)求強(qiáng)制關(guān)閉連接; 添加日志信息,追溯攻擊報(bào)文和源頭; 對(duì)攻擊服務(wù)的IP(上海阿里云的)進(jìn)行舉報(bào);
但沒(méi)多久,黑客又找上門來(lái)了,十天半月來(lái)一次攻擊,好像知道服務(wù)IP和后臺(tái)代碼似的,陰魂不散。
這不,今天被逮到了,而且之前添加了日志打印,也拿到了攻擊的報(bào)文內(nèi)容,復(fù)現(xiàn)了攻擊操作。
//?攻擊者第一次嘗試的報(bào)文
8000002872FE1D130000000000000002000186A00001977C0000000000000000000000000000000000000000
//?攻擊者第二次嘗試的報(bào)文
8000002872FE1D130000000000000002000186A00001977C00000000000000000000000000000000
上述報(bào)文,第一次的報(bào)文觸發(fā)了攻擊,第二次的報(bào)文沒(méi)有影響(與正常業(yè)務(wù)報(bào)文格式無(wú)異)。
下面就帶大家分析分析攻擊的邏輯和代碼中存在的漏洞。
知識(shí)儲(chǔ)備
要了解攻擊的原理,我們需要有一定的Netty技術(shù)知識(shí)。關(guān)于Netty如何實(shí)現(xiàn)客戶端和服務(wù)器端的代碼這里就不展開(kāi)了,可以看一下實(shí)現(xiàn)實(shí)例:https://github.com/secbr/netty-all/tree/main/netty-decoder
我們重點(diǎn)了解一下自定義解碼器和io.netty.buffer.ByteBuf。其中自定義解碼器用于對(duì)報(bào)文進(jìn)行解析,而報(bào)文內(nèi)容通過(guò)ByteBuf進(jìn)行緩存?zhèn)鬏敗?/span>
上面的攻擊報(bào)文格式表明,黑客已經(jīng)“猜到”我們是基于16進(jìn)制Btye格式進(jìn)行內(nèi)容傳輸?shù)模ê诳途谷灰仓溃?/span>
自定義解碼器
要自定義解碼器,繼承MessageToMessageDecoder類并實(shí)現(xiàn)decode方法即可,下面展示一下示例代碼:
public?class?MyDecoder?extends?MessageToMessageDecoder?{
????@Override
????protected?void?decode(ChannelHandlerContext?ctx,?ByteBuf?in,?List 其中解析報(bào)文的邏輯便是在decode方法內(nèi)進(jìn)行處理。其中ByteBuf in就是接收傳入報(bào)文的容器,而List
下面來(lái)看一下有bug的代碼(已經(jīng)過(guò)脫敏處理):
protected?void?decode(ChannelHandlerContext?ctx,?ByteBuf?in,?List上面的代碼在跑正常業(yè)務(wù)時(shí)是沒(méi)問(wèn)題的,但當(dāng)被攻擊時(shí),就進(jìn)入了死循環(huán)。因此,導(dǎo)致雖然在業(yè)務(wù)處理時(shí)添加了關(guān)閉連接的操作也是無(wú)效的。
在分析上面代碼之前,我們還得先詳細(xì)分析一下ByteBuf的原理。
ByteBuf的原理
ByteBuf中會(huì)維護(hù)兩個(gè)索引:一個(gè)索引(readIndex)用于讀取,一個(gè)索引(writeIndex)用于寫入。
當(dāng)從ByteBuf讀取時(shí),readIndex會(huì)被遞增已經(jīng)被讀取的字節(jié)數(shù),當(dāng)向ByteBuf中寫入數(shù)據(jù)時(shí),writeIndex也會(huì)被遞增。

上面圖以攻擊的報(bào)文為例進(jìn)行展示,攻擊者用了44個(gè)字節(jié)的報(bào)文進(jìn)行攻擊。由于使用的是16進(jìn)制,所以兩個(gè)字符占用1個(gè)字節(jié)。
readIndex和writeIndex的起始位置的索引位置都為0,當(dāng)執(zhí)行ByteBuf中的readXXX或writeXXX方法時(shí),會(huì)推進(jìn)對(duì)應(yīng)的索引。當(dāng)執(zhí)行setXXX或getXXX方法的操作時(shí)則不會(huì)。
了解了ByteBuf的基本處理原理之后,我們就來(lái)對(duì)照攻擊者的報(bào)文和源代碼來(lái)進(jìn)行攻擊過(guò)程的還原。
攻擊還原
下面直接通過(guò)源代碼一步步的分析,主要涉及ByteBuf類的方法。有效攻擊的報(bào)文為上面提到的第一個(gè)報(bào)文。
//?攻擊者第一次嘗試的報(bào)文
8000002872FE1D130000000000000002000186A00001977C0000000000000000000000000000000000000000
下面來(lái)看代碼:
int?readableBytes?=?in.readableBytes();
這行代碼通過(guò)readableBytes方法獲取到當(dāng)前ByteBuf中可以讀到的字節(jié)數(shù),上述攻擊報(bào)文88個(gè)字符,所以這里得到44個(gè)字節(jié)。
當(dāng)readableBytes大于3時(shí)便進(jìn)行具體的解析處理:
in.skipBytes(2);
很明顯,通過(guò)skipBytes方法跳過(guò)了兩個(gè)字節(jié)。

int?pkgLength?=?in.readUnsignedShort();
通過(guò)readUnsignedShort方法,獲得了2個(gè)字節(jié)的內(nèi)容,這兩個(gè)字節(jié)對(duì)應(yīng)的十六進(jìn)制值為“0028”,對(duì)應(yīng)十進(jìn)制為“40”。這兩個(gè)字節(jié)在報(bào)文中的含義是(部分或整個(gè))報(bào)文的長(zhǎng)度。
報(bào)文的長(zhǎng)度往往有兩種算法:第一,長(zhǎng)度代表整個(gè)報(bào)文的長(zhǎng)度(業(yè)務(wù)中使用的含義);第二,長(zhǎng)度代表除前4個(gè)字節(jié)之后的報(bào)文長(zhǎng)度(攻擊者使用的含義)。
其實(shí),正是因?yàn)檫@個(gè)長(zhǎng)度含義的定義,導(dǎo)致正常業(yè)務(wù)可以執(zhí)行,而攻擊報(bào)文會(huì)進(jìn)入死循環(huán)。
下面繼續(xù)分享代碼:
in.readerIndex(in.readerIndex()?-?4);
經(jīng)上面的skipBytes和readUnsignedShort的調(diào)用,ByteBuf的讀索引已經(jīng)跑到了第4個(gè)字節(jié)上了。所以這里in.readerIndex()返回的值為4,而in.readerIndex(4-4)的作用就是將讀索引重置為0,也就是從頭開(kāi)始讀。
if?(in.readableBytes()?????return;
}
這個(gè)判斷是在讀索引移動(dòng)到0之后,看看報(bào)文的可讀字節(jié)數(shù)是否小于報(bào)文內(nèi)容中指定的字節(jié)數(shù)。很顯然,in.readableBytes()對(duì)應(yīng)的值為44個(gè)字節(jié),而pkgLength為40個(gè)字節(jié),不會(huì)進(jìn)行return。
out.add(in.readBytes(pkgLength));
讀取40個(gè)字節(jié),進(jìn)行輸出。還剩下4個(gè)字節(jié)的內(nèi)容,readIndex指向第40個(gè)字節(jié)的位置。
readableBytes?=?in.readableBytes();
由于readIndex已經(jīng)指向第40個(gè)字節(jié),所以此時(shí)可讀字節(jié)數(shù)為4。
然后,進(jìn)入第二輪循環(huán)。此時(shí),神奇的情況就出現(xiàn)了。我們可以看到攻擊的后4個(gè)字節(jié)的報(bào)文值全為0。
in.skipBytes(2);
int?pkgLength?=?in.readUnsignedShort();
因此跳過(guò)2個(gè)字節(jié)后,readIndex為42,pkgLength獲取第43和44字節(jié)的值:0。
in.readerIndex(in.readerIndex()?-?4);
上述代碼又將readIndex設(shè)置到第40個(gè)字節(jié)。
if?(in.readableBytes()?????return;
}
此時(shí)會(huì)發(fā)現(xiàn)readableBytes返回值為4,但pkgLength已經(jīng)變?yōu)?了,不會(huì)return。
接下讀取內(nèi)容時(shí)就出現(xiàn)狀況了:
out.add(in.readBytes(pkgLength));
//?這里還剩下4個(gè)字節(jié)
readableBytes?=?in.readableBytes();
上述readBytes讀取字節(jié)數(shù)為0,而readableBytes始終為4。此時(shí),整個(gè)while循環(huán)進(jìn)入了死循環(huán),大量消耗CPU資源。
此時(shí)還沒(méi)完,最多只是把CPU跑到100%,但是當(dāng)不停的將空字符寫到接收數(shù)據(jù)的緩沖區(qū)域之后,緩沖區(qū)開(kāi)始瘋狂調(diào)用處理業(yè)務(wù)的Handler,進(jìn)一步侵入到業(yè)務(wù)處理邏輯當(dāng)中。
雖然業(yè)務(wù)邏輯層做了判斷,也進(jìn)行了連接的關(guān)閉,但此時(shí)已經(jīng)與連接無(wú)關(guān),while循環(huán)已經(jīng)進(jìn)入死循環(huán),關(guān)掉連接也沒(méi)什么作用。同時(shí),業(yè)務(wù)層有日志輸出,大量的日志輸出到磁盤當(dāng)中,導(dǎo)致磁盤被刷滿。
最終導(dǎo)致服務(wù)器的CPU監(jiān)控和磁盤監(jiān)控報(bào)警。乍一看,還以為是又一次DDoS攻擊……
小結(jié)
總結(jié)一下,其實(shí)就是攻擊者傳輸?shù)膱?bào)文長(zhǎng)度和報(bào)文內(nèi)指定的長(zhǎng)度不一致,導(dǎo)致了解析報(bào)文時(shí)進(jìn)入了死循環(huán)。
問(wèn)題一旦發(fā)現(xiàn),解決起來(lái)就很容易了。其實(shí)通過(guò)這件事也得到一些啟發(fā)。第一,遇到問(wèn)題,迎難而上解決掉它,往往是最好的方案,逃避只能將問(wèn)題往后拖,但并不能解決掉。第二,只要靜下心來(lái)分析,一步步分析,很少有解決不掉的問(wèn)題。
往期推薦
如果你覺(jué)得這篇文章不錯(cuò),那么,下篇通常會(huì)更好。關(guān)注一下【公眾號(hào)】或添加微信好友(微信號(hào):541075754),都是OK的。
? 和花一輩子都看不清的人,
? 注定是截然不同的搬磚生涯。



