構建 Go 語言的動態插裝 Agent
我們在 Sqreen (譯註:一家安全公司)一直致力於讓的安全保護透明無感、接入平滑。最近,我們發佈了 Sqreen for Go,它可以在不需要任何代碼改動的情況下檢測和阻止 Go 應用中的安全問題。爲了實現這一點,我們利用了動態插裝(dynamic instrumentation)在運行時向程序中插入額外的安全邏輯。作爲我們動態插裝系列的最新發文,這篇文章將要討論動態插裝,Sqreen 的 Go Agent,以及我們是如何把他們結合在一起的。
這種檢測和保護是基於 microagent 的,microagent 是一個從運行的程序中獲取數據,並與外部組件通信以報告統計信息或特定數據的組件。這裏描述的 的,microagent 是一個 Go 包,它與 Sqreen 的後端服務通信。它專門設計用於安全地插裝到生產系統中,因此我們用了精妙的(sophisticated)方法來最大化其穩定性,同時讓它對性能的影響儘可能小。
運行中的 Go 程序與 Sqreen 概覽:Agent 協程自動啓動,並根據我們面板中的配置對程序進行插裝。
Sqreen 的動態插裝有很多優點:
-
不需要對應用代碼進行修改,因此安裝簡易便捷;
-
完整覆蓋程序堆棧,包括所有第三方庫、標準庫和語言的運行時;
-
安全邏輯可以由中心化的配置面板控制和更新,不需要重新部署運行中的應用。
Agent 可以主要分爲三個部分:
-
插裝引擎,處理讓函數(function)增加額外業務邏輯的底層機制;
-
安全規則引擎,通過調用插裝引擎來爲函數增加安全防護邏輯。它遵循由 Sqreen 後端下發的高級(high-level)邏輯描述,例如保護 SQL 語句受到 SQL 注入攻擊等;
-
數據記錄機制,異步收集安全防護的數據,並定期發送到 Sqreen 後端。
接下來的章節詳細介紹了我們如何在 Go 語言中解決這個非常有挑戰的問題。Go 語言中的標準庫 database/sql
將被用作 SQL 注入工具防護組件的示例。
插裝 Go 代碼
運行時插裝在動態語言中有廣泛的使用,通常稱爲 Monkey Patching。但是 Go 是一門強類型的靜態語言,用它構建的代碼由 Go 編譯器變異成包含二進制機器碼的程序文件:
在運行時,操作系統和硬件加載並執行這個二進制程序文件:
因此,運行中的 Go 程序其實就是一個運行中的二進制程序。我們通常認爲,運行時插裝去修改它的二進制代碼是不安全、有風險的,在生產環境某些場景中甚至是不可能的。
選擇正確的插裝方案
前面的討論已經告訴我們所有能做插裝的地方了:由開發者手動使用的工具進行源碼級別的插裝,到針對運行中的程序的硬件級別的插裝。這意味着我們有很多方案可以選擇。而對於 Sqreen 來說,就是要選擇最適合 Sqreen Agent 場景的方案:
-
對開發者友好:易於開發和部署;
-
適用於生產環境:能將安全防護應用於運行中的程序,高效,可靠且安全。
由此可得出以下的表格:
長話短說,我們選擇編譯時插裝技術:
-
它對生產環境沒有影響,因爲它只在開發環境完成;
-
它由編譯器自動、透明地完成;
-
它完全在用戶空間完成,不需要往返操作系統(內核);
-
它具有可移植性,因爲它不依賴操作系統或者硬件支持。
而其他的插裝技術:
-
二進制層面的插裝很難在生產環境做到安全可靠;
-
基於 Trap 的技術(例如用戶空間探針)較爲低效,因爲需要硬件中斷。同時它也不具備可移植性,且需要不安全的執行權限;
-
源代碼插裝對於開發者來說很難管理,並且只能處理應用程序代碼範圍的插裝。
基於以上的分析,我們決定使用編譯時插裝的技術來爲 Go 語言程序添加運行時插裝能力。
鉤子策略(Hooking strategy)
我們需要編譯器爲 Go 程序中的函數添加插裝鉤子點位(hook points),以便讓 Sqreen 的 microagent 監控和保護函數執行。例如,我們想在 SQL 執行函數添加鉤子,檢測函數參數中的 SQL 語句是否存在注入情況,並在檢測到(注入)攻擊時中止函數調用。
爲此,我們的鉤子策略允許我們通過以下方式監控和保護函數的執行:
-
讀取函數參數,監控甚至檢測攻擊;
-
可以立即返回,以安全地中止函數調用,防止發生攻擊。
SQL 執行函數鉤子啓用時,SQL 注入保護的示例:當語句中檢測到注入時,函數需要馬上中止,返回一個不爲
nil
的錯誤。
以下代碼段展示了一個被插裝的 Go 函數,它使用了之前描述的鉤子策略:
// 這是一個函數的示例,返回給定參數的json序列化結果。
//
// 該函數插裝了一個演示的鉤子,監控函數調用,以實現保護
// 控制流程,並在必要時中止調用。
//
// 爲此,以下插裝代碼塊由兩部分組成:
//
// 1. The prolog: 它是插裝的起點,掛鉤在正常代碼執行之前,
// 對應其輸入參數。它會返回一個布爾值和 epilog 鉤子,
// 當函數調用必須中止時布爾值爲 true。
//
// 2. The epilog: 它是由 prelog 返回的函數,用於掛鉤在函
// 數返回時以及對應函數返回值。它由 defer 執行,以保證在
// 任何返回情況下均會執行到。
//
func myInstrumentedFunction(a int) (result []byte, err error) {
// 插裝代碼段
{
// prelog 鉤子啓用。
// 注意這個示例不是線程安全的,但我們的實際實現中加載函數值
// 是原子(atomically)的。
if myInstrumentedFunctionHook != nil {
// 用函數參數調用 prelog 鉤子,並檢查返回值
epilog, abort := myInstrumentedFunctionHook(a)
// 不管 abort 值如何,epilog 方法總會由 defer 執行以
// 觀測所有返回情況。
if epilog != nil {
defer epilog(&result, &err)
}
// 如果 abort 值爲 true,則立即從函數中返回。返回前可能
// 執行 epilog 函數。
if abort {
return
}
}
}
// 正常代碼
return json.Marshal(a)
}
// 插裝函數的 prolog 鉤子
var myInstrumentedFunctionHook myInstrumentedFunctionPrologHookType
// 函數的 prelog 和 epilog 鉤子定義,強類型並與函數簽名保持一致。
type (
myInstrumentedFunctionPrologHookType func(a int) (myInstrumentedFunctionEpilogHookType, bool)
myInstrumentedFunctionEpilogHookType func(result *[]byte, err *error)
)
Copy
你可以在 Go Playground 運行完整的示例代碼:https://play.golang.org/p/zAQaf_rGaRs
編譯時(Compile-Time)插裝:向 Go 程序添加鉤子點位
Go 編譯器默認不提供這種鉤子點位插裝。但是,代碼生成已經很常見,並且在 Go 語言中也有廣泛使用。例如,Go 編譯器在代碼覆蓋率或競態檢測中都有使用到這種技術。Go 語言標準庫有解析、修改、重新生成 Go 代碼所需的所有工具。因此,我們選擇在一個獨立的插裝工具中實現安全的源碼層面插裝,使其能與 Go 編譯器集成,生成插裝後的 Go 程序文件。
集成 Sqreen 插裝工具到 Go 編譯器中並生成插裝後的 Go 程序文件。
這個工具會在每次編譯時被 Go 編譯器調用,並插裝所有定義了的函數。
SQL 執行函數源碼層級的插裝示例:接收 Go 源碼文件作爲輸入,輸出增加了鉤子點位插裝的新源碼。
原有的源碼文件不會被修改,插裝後的源碼會生成到編譯器的構建目錄(build directory)中。因此,可以使用 Go 構建選項 -work
來查看插裝後的代碼,它會打印結果並保留構建目錄。
在編譯器層級處理插裝的另一個好處是,可以完整地對所有 Go 語言涉及到的組件進行插裝。我們只對少量的 Go 庫包進行了插裝,這些庫包記錄在文檔中:https://docs.sqreen.com/go/instrumentation/#list-of-instrumented-packages
運行時插裝:掛載安全防護邏輯到鉤子點位
基於上述的編譯時插裝方案,Sqreen 的 Go Agent 現在可以通過鉤子表(一個由工具生成的 Go 數組)找到鉤子點位,並在需要時進行插裝。在 SQL 注入防護的例子中,當我們在面板上啓用該功能後,Agent 會收到 SQL 注入安全的規則。它會包括插裝 SQL 執行函數(當前一共有 3 個:prepare
,exec
和 query
)所需的信息,以及 SQL 注入防護邏輯。
藉助標準庫中的 reflection
,鉤子點位的 Go 類型(譯註:此處應指函數位置、函數簽名等信息)也用於檢查和掛載防護邏輯。掛載原子操作,在併發使用場景下也是安全的。
在這個示例中,Agent 收到 Sqreen 後端的 SQL 注入防護指令。當應用設置中的防護開啓,Agent 檢索鉤子表中的鉤子,檢驗是否和將要掛載的函數類型信息匹配,最後原子地掛載到鉤子點位上。
掛載之後,函數的執行就能被觀察到,更重要的是,能在檢測到攻擊時中止執行。安全防護可以在函數調用上下文進行它的檢測,並藉助 Go 函數的簽名,安全地中止函數調用,返回一個不爲 nil
的錯誤。
我們的安全防護還可以自動通過取消(cancel) Go request context 和根據面板配置響應 HTTP 請求,來中止 HTTP 調用處理。
HTTP 請求由 Sqreen 自動管理:根據面板配置進行響應,以及通過取消 Go request context 來中止請求,並正確地傳播中止信息。
數據傳輸
所有加在請求處理流程中的操作都是設計成異步的,以規避對響應時間的影響。當一個新的 HTTP 請求開始時,我們的中間件函數添加了一些數據結構到請求中,它們可以在掛載的防護中被獲取到,用以收集安全相關的信息,例如攻擊詳情、指標和事件。當請求中止時,請求的數據結構通過非阻塞的 Go channel 異步送往 Agent,這樣不會時阻塞到 HTTP 請求的 handler 把它發出去。
Agent 協程大部分時間都在休眠,僅在 Go channel 有數據時被 Go 調度器喚醒。Agent 收集數據,暫存攢批,最後定時發送至我們的後端服務(默認 20 秒一次)。我們的安全防護不收集敏感數據,Agent 會強制清除所有已發送至後端的數據。總而言之,這個實現就是個簡單的異步 Go 程序。
性能和魯棒性
Sqreen Go Agent 只使用了標準的 Go 語言技術,但是它所充當的角色需要我們仔細考慮以下幾方面:
-
我們關注它對 Go 調度器的影響以及併發場景下的壓力。我們通過設定很少的、固定數量的 goroutine 數(目前爲 3)來減輕潛在影響。其中一個不錯的例子是我們的指標管理,它是基於原子操作的、無鎖的。這些都是爲了避免 Go channel 替代方案中引入在阻塞操作進而對調度產生影響。這種選擇使其執行開銷可以忽略不計,並且不涉及任何數據隊列管理。這對於存在大量併發、可以管理上千個 goroutines 的 Go 服務來說是非常重要的;
-
我們通過執行的 deadline 來保證 Agent 有時間和內存用量的邊界限制(特別是對於我們掛載的防護),以及各種數據結構的最大長度;
-
我們關注垃圾回收的壓力,通過使用內存池來規避頻繁的小內存分配過程。
Agent 還有針對通信失敗、大量請求流量、內部發生非預期的行爲的魯棒性設計,其工作不依賴於後端服務,並且可以重啓或者停止工作來保證最大程度的安全可靠。
展望未來
我們在這篇博客中描述了生產級插裝 Agent 的上層概念。動態插裝 Agent 可以用於處理很多不同的任務,例如性能監控、錯誤監控或安全防護。
在 Sqreen,我們的 Go Agent 利用動態插裝技術來保護應用,並且提高 Go 應用的可觀測性。如果對 Sqreen 感興趣或者想動手試試 Go Agent,可以來 Sqreen 官網轉一轉。
原文地址:https://jiekun.dev/posts/dynamic_instrumentation_agent/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/vliRmGoGWJVJRUM3t0dLLA