go 根据输入类型执行对应的方法_安全很重要:Go项目的安全评估技术
在今年夏天我們對Kubernetes的評估成功之后,我們收到了大量Go項目的安全評估需求。為此,我們將在其他編譯語言中使用過的安全評估技術和策略調整適配到多個Go項目中。
我們從了解語言的設計開始,識別出開發人員可能無法完全理解語言語義特性的地方。多數這些被誤解的語義來自我們向客戶報告的調查結果以及對語言本身的獨立研究。盡管不是詳盡無遺,但其中一些問題領域包括作用域、協程、錯誤處理和依賴管理。值得注意的是,其中許多與運行時沒有直接關系。默認情況下,Go運行時本身的設計是安全的,避免了很多類似C語言的漏洞。
對根本原因有了更好地理解后,我們搜索了現有的能幫助我們快速有效檢測客戶端代碼庫的工具。結果我們找到一些靜態和動態開源工具,其中包括了一些與Go無關的工具。為了配合這些工具使用,我們還確定了幾種有助于檢測的編譯器配置。
一. 靜態分析
由于Go是一種編譯型語言,因此編譯器在生成二進制可執行文件之前就檢測并杜絕了許多潛在的錯誤模式。雖然對于新的Go開發人員來說,這些編譯器的輸出比較煩,但是這些警告對于防止意外行為以及保持代碼的清潔和可讀性非常重要。
靜態分析趨向于捕獲很多未包括在編譯器錯誤和警告中的懸而未決的問題。在Go語言生態系統中,有許多不同的工具,例如go-vet、staticcheck和analysis包中的工具。這些工具通常會識別出諸如變量遮蔽、不安全的指針使用以及未使用的函數返回值之類的問題。調查這些工具顯示警告的項目區域通常會發現可被利用(進行安全攻擊)的功能特性。
這些工具絕不是完美的。例如,go-vet可能會錯過非常常見的問題,例如下面例子中這種。
package?mainimport?"fmt"
func?A()?(bool,?error)?{?return?false,?fmt.Errorf("I?get?overridden!")?}
func?B()?(bool,?error)?{?return?true,?nil?}
func?main()?{
????aSuccess,?err?:=?A()
????bSuccess,?err?:=?B()
????if?err?!=?nil?{
????????fmt.Println(err)
????}
????fmt.Println(aSuccess,?":",?bSuccess)
}
這個例子未使用A函數的err返回值,并在表達式左側為bSuccess賦值期間立即重新對err做了賦值。編譯器針對這種情況不會提供警告,而go-vet也不會檢測到該問題;errcheck也不會。實際上,能成功識別這種情況的工具是前面提到的staticcheck和ineffassign,它們將A的錯誤返回值標識為未使用或無效。
示例程序的輸出以及errcheck,go-vet,staticcheck和ineffassign的檢查結果如下:
$?go?run?.false?:?true
$?errcheck?.
$?go?vet?.
$?staticcheck?.
main.go:5:50:?error?strings?should?not?be?capitalized?(ST1005)
main.go:5:50:?error?strings?should?not?end?with?punctuation?or?a?newline?(ST1005)
main.go:10:12:?this?value?of?err?is?never?used?(SA4006)
$?ineffassign?.
main.go:10:12:?ineffectual?assignment?to?err
當您深入研究此示例時,您可能會想知道為什么編譯器沒有針對此問題發出警告。當程序中有未使用的變量時,Go編譯器將出錯,但此示例成功通過編譯。這是由“短變量聲明”的語義引起的。下面是短變量聲明的語法規范:
ShortVarDecl?=?IdentifierList?":="?ExpressionList?.根據規范,短變量聲明具有重新聲明變量的特殊功能,只要:
重新聲明在多變量短聲明中。
重新聲明的變量在同一代碼塊或函數的參數列表中聲明較早。
重新聲明的變量與先前的聲明具有相同的類型。
聲明中至少有一個非空白變量("_")是新變量。
所有這些約束在上一個示例中均得到滿足,從而防止了編譯器針對此問題產生編譯錯誤。
許多工具都具有類似這樣的極端情況,即它們在識別相關問題或識別問題但以不同的方式描述時均未成功。使問題復雜化的是,這些工具通常需要先構建Go源代碼,然后才能執行分析。如果分析人員無法輕松構建代碼庫或其依賴項,這將使第三方安全評估變得復雜。
盡管存在這些困難,但只要付出一點點努力,這些工具就可以很好地提示我們在項目中從何處查找問題。我們建議至少使用gosec、go-vet和staticcheck。對大多數代碼庫而言,這些工具具有良好的文檔和人機工效。他們還提供了針對常見問題的多種檢查(例如ineffassign或errcheck)。但是,要對特定類型的問題進行更深入的分析,可能必須使用更具體的分析器,直接針對SSA開發定制的工具或使用semmle。
二. 動態分析
一旦執行了靜態分析并檢查了結果,動態分析技術通常是獲得更深層結果的下一步。由于Go的內存安全性,動態分析通常發現的問題是導致硬崩潰(hard crash)或程序狀態無效的問題。Go社區已經建立了各種工具和方法來幫助識別Go生態系統中這些類型的問題。此外,可以改造現有的與語言無關的工具以滿足Go動態分析的需求,我們將在下面展示。
1. 模糊測試
Go語言領域中最著名的動態測試工具可能是Dimitry Vyukov的go-fuzz了。該工具使您可以快速有效地實施模糊測試,并且它已經有了不錯的戰利品。更高級的用戶在獵錯過程中可能還會發現分布式的模糊測試和libFuzzer的支持非常有用。
Google還發布了一個更原生的模糊器(fuzzer),它擁有一個與上面的go-fuzz相似的名字:gofuzz。它通過初始化具有隨機值的結構來幫助用戶。與Dimitry的go-fuzz不同,Google的gofuzz不會生成夾具(harness)或協助提供存儲崩潰時的輸出信息、模糊輸入或任何其他類型的信息。盡管這對于測試某些目標可能是不利的,但它使輕量級且可擴展的框架成為可能。
為了簡潔起見,我們請您參考各自自述文件中這兩個工具的示例。
google/gofuzz#gofuzz
dvyukov/go-fuzz#usage
2. 屬性測試(property test)
譯注:屬性測試指編寫對你的代碼來說為真的邏輯語句(即“屬性”),然后使用自動化工具來生成測試輸入(一般來說,是指某種特定類型的隨機生成輸入數據),并觀察程序接受該輸入時屬性是否保持不變。如果某個輸入違反了某一條屬性,則證明用戶程序存在錯誤 - 摘自網絡。
與傳統的模糊測試方法不同,Go的testing包(通常用于單元測試和集成測試)為Go函數的“黑盒測試” 提供了testing/quick子包。換句話說,它提供了屬性測試的基本原語。給定一個函數和生成器,該包可用于構建夾具,以測試在給定輸入生成器范圍的情況下潛在的屬性違規。以下示例是直接摘自官方文檔。
func?TestOddMultipleOfThree(t?*testing.T)?{????f?:=?func(x?int)?bool?{
????????y?:=?OddMultipleOfThree(x)
????????return?y%2?==?1?&&?y%3?==?0
????}
????if?err?:=?quick.Check(f,?nil);?err?!=?nil?{
????????t.Error(err)
????}
}
上面示例正在測試OddMultipleOfThree函數,其返回值應始終為3的奇數倍。如果不是,則f函數將返回false并將違反該屬性。這是由quick.Check功能檢測到的。
雖然此包提供的功能對于屬性測試的簡單應用是可以接受的,但重要的屬性通常不能很好地適合這種基本界面。為了解決這些缺點,誕生了leanovate/gopter框架。Gopter為常見的Go類型提供了各種各樣的生成器,并且支持您創建與Gopter兼容的自定義生成器。通過gopter/commands子包還支持狀態測試,這對于測試跨操作序列的屬性是否有用很有有幫助。除此之外,當違反屬性時,Gopter會縮小生成的輸入。請參閱下面的輸出中輸入收縮的屬性測試的簡要示例。
Compute結構的測試夾具:
package?main_testimport?(
??"github.com/leanovate/gopter"
??"github.com/leanovate/gopter/gen"
??"github.com/leanovate/gopter/prop"
??"math"
??"testing"
)
type?Compute?struct?{
??A?uint32
??B?uint32
}
func?(c?*Compute)?CoerceInt?()?{?c.A?=?c.A?%?10;?c.B?=?c.B?%?10;?}
func?(c?Compute)?Add?()?uint32?{?return?c.A?+?c.B?}
func?(c?Compute)?Subtract?()?uint32?{?return?c.A?-?c.B?}
func?(c?Compute)?Divide?()?uint32?{?return?c.A?/?c.B?}
func?(c?Compute)?Multiply?()?uint32?{?return?c.A?*?c.B?}
func?TestCompute(t?*testing.T)?{
??parameters?:=?gopter.DefaultTestParameters()
??parameters.Rng.Seed(1234)?//?Just?for?this?example?to?generate?reproducible?results
??properties?:=?gopter.NewProperties(parameters)
??properties.Property("Add?should?never?fail.",?prop.ForAll(
????func(a?uint32,?b?uint32)?bool?{
??????inpCompute?:=?Compute{A:?a,?B:?b}
??????inpCompute.CoerceInt()
??????inpCompute.Add()
??????return?true
????},
????gen.UInt32Range(0,?math.MaxUint32),
????gen.UInt32Range(0,?math.MaxUint32),
??))
??properties.Property("Subtract?should?never?fail.",?prop.ForAll(
????func(a?uint32,?b?uint32)?bool?{
??????inpCompute?:=?Compute{A:?a,?B:?b}
??????inpCompute.CoerceInt()
??????inpCompute.Subtract()
??????return?true
????},
????gen.UInt32Range(0,?math.MaxUint32),
????gen.UInt32Range(0,?math.MaxUint32),
??))
??properties.Property("Multiply?should?never?fail.",?prop.ForAll(
????func(a?uint32,?b?uint32)?bool?{
??????inpCompute?:=?Compute{A:?a,?B:?b}
??????inpCompute.CoerceInt()
??????inpCompute.Multiply()
??????return?true
????},
????gen.UInt32Range(0,?math.MaxUint32),
????gen.UInt32Range(0,?math.MaxUint32),
??))
??properties.Property("Divide?should?never?fail.",?prop.ForAll(
????func(a?uint32,?b?uint32)?bool?{
??????inpCompute?:=?Compute{A:?a,?B:?b}
??????inpCompute.CoerceInt()
??????inpCompute.Divide()
??????return?true
????},
????gen.UInt32Range(0,?math.MaxUint32),
????gen.UInt32Range(0,?math.MaxUint32),
??))
??properties.TestingRun(t)
}
執行測試夾具并觀察屬性測試的輸出(除法失敗):
user@host:~/Desktop/gopter_math$?go?test+?Add?should?never?fail.:?OK,?passed?100?tests.
Elapsed?time:?253.291μs
+?Subtract?should?never?fail.:?OK,?passed?100?tests.
Elapsed?time:?203.55μs
+?Multiply?should?never?fail.:?OK,?passed?100?tests.
Elapsed?time:?203.464μs
!?Divide?should?never?fail.:?Error?on?property?evaluation?after?1?passed
???tests:?Check?paniced:?runtime?error:?integer?divide?by?zero
goroutine?5?[running]:
runtime/debug.Stack(0x5583a0,?0xc0000ccd80,?0xc00009d580)
????/usr/lib/go-1.12/src/runtime/debug/stack.go:24?+0x9d
github.com/leanovate/gopter/prop.checkConditionFunc.func2.1(0xc00009d9c0)
????/home/user/go/src/github.com/leanovate/gopter/prop/check_condition_func.g
??o:43?+0xeb
panic(0x554480,?0x6aa440)
????/usr/lib/go-1.12/src/runtime/panic.go:522?+0x1b5
_/home/user/Desktop/gopter_math_test.Compute.Divide(...)
????/home/user/Desktop/gopter_math/main_test.go:18
_/home/user/Desktop/gopter_math_test.TestCompute.func4(0x0,?0x0)
????/home/user/Desktop/gopter_math/main_test.go:63?+0x3d
#?snip?for?brevity;
ARG_0:?0
ARG_0_ORIGINAL?(1?shrinks):?117380812
ARG_1:?0
ARG_1_ORIGINAL?(1?shrinks):?3287875120
Elapsed?time:?183.113μs
---?FAIL:?TestCompute?(0.00s)
????properties.go:57:?failed?with?initial?seed:?1568637945819043624
FAIL
exit?status?1
FAIL????_/home/user/Desktop/gopter_math?0.004s
3. 故障注入
在攻擊Go系統時,故障注入令人驚訝地有效。我們使用此方法發現的最常見錯誤包括對error類型的處理。因為error在Go中只是一種類型,所以當它返回時,它不會像panic語句那樣自行改變程序的執行流程。我們通過強制生成來自最低級別(內核)的錯誤來識別此類錯誤。由于Go會生成靜態二進制文件,因此必須在不使用LD_PRELOAD的情況下注入故障。我們的工具之一KRF使我們能夠做到這一點。
在我們最近的Kubernetes代碼庫評估中,我們使用KRF找到了一個vendored依賴深處的問題,只需通過隨機為進程和其子進程發起的read和write系統調用制造故障。該技術對通常與底層系統交互的Kubelet十分有效。該錯誤是在ionice命令出現錯誤時觸發的,未向STDOUT輸出信息并向STDERR發送錯誤。記錄錯誤后,將繼續執行而不是將STDERR的錯誤返回給調用方。這導致STDOUT后續被索引,從而導致索引超出范圍導致運行時panic。
下面是導致kubelet panic的調用棧信息:
E0320?19:31:54.493854????6450?fs.go:591]?Failed?to?read?from?stdout?for?cmd?[ionice?-c3?nice?-n?19?du?-s?/var/lib/docker/overlay2/bbfc9596c0b12fb31c70db5ffdb78f47af303247bea7b93eee2cbf9062e307d8/diff]?-?read?|0:?bad?file?descriptorpanic:?runtime?error:?index?out?of?range
goroutine?289?[running]:
k8s.io/kubernetes/vendor/github.com/google/cadvisor/fs.GetDirDiskUsage(0xc001192c60,?0x5e,?0x1bf08eb000,?0x1,?0x0,?0xc0011a7188)
????/workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/google/cadvisor/fs/fs.go:600?+0xa86
k8s.io/kubernetes/vendor/github.com/google/cadvisor/fs.(*RealFsInfo).GetDirDiskUsage(0xc000bdbb60,?0xc001192c60,?0x5e,?0x1bf08eb000,?0x0,?0x0,?0x0)
????/workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/google/cadvisor/fs/fs.go:565?+0x89
k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common.(*realFsHandler).update(0xc000ee7560,?0x0,?0x0)
????/workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common/fsHandler.go:82?+0x36a
k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common.(*realFsHandler).trackUsage(0xc000ee7560)
????/workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common/fsHandler.go:120?+0x13b
created?by
k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common.(*realFsHandler).Start
????/workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common/fsHandler.go:142?+0x3f
下面例子:記錄了STDERR日志但未將error返回調用方。
stdoutb,?souterr?:=?ioutil.ReadAll(stdoutp)if?souterr?!=?nil?{
????klog.Errorf("Failed?to?read?from?stdout?for?cmd?%v?-?%v",?cmd.Args,?souterr)
}
當stdout為空,也嘗試使用索引,這是運行時出現panic的原因:
usageInKb,?err?:=?strconv.ParseUint(strings.Fields(stdout)[0],?10,?64)更完整的包含重現上述問題的步驟,可參見我們的Kubernetes最終報告附錄G(第109頁),那里詳細介紹了針對Kubelet使用KRF的方法。
Go的編譯器還允許將測量工具包含在二進制文件中,從而可以在運行時檢測race狀況,這對于將潛在的race識別為攻擊者非常有用,但也可以用來識別對defer、panic和recover的不正確處理。我們構建了Trailofbits/on-edge來做到這一點:識別函數入口點和函數panic點之間的全局狀態變化,并通過Go race檢測器"泄露"此信息。有關OnEdge的更多詳細信息,請參見我們以前的博客文章“在Go中選擇正確panic的方式”。
實踐中,我們建議使用:
dvyukov/go-fuzz為組件解析輸入建立夾具
google/gofuzz用于測試結構驗證
leanovate/gopter用于增強現有的單元和集成測試以及測試規范的正確性
Trailofbits/krf和Trailofbits/on-edge用于測試錯誤處理。
除KRF外,所有這些工具在實踐中都需要付出一些努力。
三. 利用編譯器的優勢
Go編譯器具有許多內置功能和指令(directive),可幫助我們查找錯誤。這些功能隱藏在各種開關中中,并且需要一些配置才能達到我們的目的。
1. 顛覆類型系統
有時在嘗試測試系統功能時,導出函數不是我們要測試的。要獲得對所需的函數的測試訪問權,可能需要重命名許多函數,以便可以將其導出,這可能會很麻煩。要解決此問題,可以使用編譯器的build指令(directive)進行名稱鏈接(name linking)以及導出系統的訪問控制。作為此功能的示例,下面的程序(從Stack Overflow答案中提取)訪問未導出的reflect.typelinks函數,并隨后迭代類型鏈接表以識別已編譯程序中存在的類型。
下面是使用linkname build directive的Stack Overflow答案的通用版本:
package?mainimport?(
????"fmt"
????"reflect"
????"unsafe"
)
func?Typelinks()?(sections?[]unsafe.Pointer,?offset?[][]int32)?{
????return?typelinks()
}
//go:linkname?typelinks?reflect.typelinks
func?typelinks()?(sections?[]unsafe.Pointer,?offset?[][]int32)func?Add(p?unsafe.Pointer,?x?uintptr,?whySafe?string)?unsafe.Pointer?{
????return?add(p,?x,?whySafe)
}
//go:linkname?add?reflect.add
func?add(p?unsafe.Pointer,?x?uintptr,?whySafe?string)?unsafe.Pointerfunc?main()?{
????sections,?offsets?:=?Typelinks()
????for?i,?base?:=?range?sections?{
????????for?_,?offset?:=?range?offsets[i]?{
????????????typeAddr?:=?Add(base,?uintptr(offset),?"")
????????????typ?:=?reflect.TypeOf(*(*interface{})(unsafe.Pointer(&typeAddr)))
????????????fmt.Println(typ)
????????}
????}
}
下面是typelinks表的輸出:
$?go?run?main.go**reflect.rtype
**runtime._defer
**runtime._type
**runtime.funcval
**runtime.g
**runtime.hchan
**runtime.heapArena
**runtime.itab
**runtime.mcache
**runtime.moduledata
**runtime.mspan
**runtime.notInHeap
**runtime.p
**runtime.special
**runtime.sudog
**runtime.treapNode
**sync.entry
**sync.poolChainElt
**syscall.Dirent
**uint8
如果需要在運行時進行更精細的控制(即,不僅僅是linkname指令),則可以編寫Go的中間匯編碼,并在編譯過程中包括它。盡管在某些地方它可能不完整且有些過時,但是teh-cmc/go-internals提供了有關Go如何組裝函數的很好的介紹。
2. 編譯器生成的覆蓋圖
為了幫助進行測試,Go編譯器可以執行預處理以生成coverage信息。這旨在標識單元測試和集成測試的測試覆蓋范圍信息,但是我們也可以使用它來標識由模糊測試和屬性測試生成的測試覆蓋范圍。Filippo Valsorda在博客文章中提供了一個簡單的示例。
3. 類型寬度安全
Go支持根據目標平臺自動確定整數和浮點數的大小。但是,它也允許使用固定寬度的定義,例如int32和int64。當混合使用自動寬度和固定寬度大小時,對于跨多個目標平臺的行為,可能會出現錯誤的假設。
針對目標的32位和64位平臺構建進行測試將有助于識別特定于平臺的問題。這些問題通常在執行驗證、解碼或類型轉換的時候發現,原因在于對源和目標類型屬性做出了不正確的假設。在Kubernetes安全評估中就有一些這樣的示例,特別是TOB-K8S-015:使用strconv.Atoi并將結果向下轉換時的溢出(Kubernetes最終報告中的第42頁),下面是這個示例。
//?updatePodContainers?updates?PodSpec.Containers.Ports?with?passed?parameters.func?updatePodPorts(params?map[string]string,?podSpec?*v1.PodSpec)?(err?error)?{
????port?:=?-1
????hostPort?:=?-1
????if?len(params["port"])?>?0?{
????????port,?err?=?strconv.Atoi(params["port"])?//?if?err?!=?nil?{return?err
????????}
????}//?(...)//?Don't?include?the?port?if?it?was?not?specified.if?len(params["port"])?>?0?{
????????podSpec.Containers[0].Ports?=?[]v1.ContainerPort{
????????????{
????????????????ContainerPort:?int32(port),?//?
????????????},
????????}
錯誤的類型寬度假設導致的溢出:
root@k8s-1:/home/vagrant#?kubectl?expose?deployment?nginx-deployment?--port?4294967377?--target-port?4294967376E0402?09:25:31.888983????3625?intstr.go:61]?value:?4294967376?overflows?int32
goroutine?1?[running]:
runtime/debug.Stack(0xc000e54eb8,?0xc4f1e9b8,?0xa3ce32e2a3d43b34)
????/usr/local/go/src/runtime/debug/stack.go:24?+0xa7
k8s.io/kubernetes/vendor/k8s.io/apimachinery/pkg/util/intstr.FromInt(0x100000050,?0xa,?0x100000050,?0x0,?0x0)
...
service/nginx-deployment?exposed
實際上,很少需要顛覆類型系統。最需要的測試目標已經是導出了的,可以通過import獲得。我們建議僅在需要助手和測試類似的未導出函數時才使用此功能。至于測試類型寬度安全性,我們建議您盡可能對所有目標進行編譯,即使沒有直接支持也是如此,因為不同目標上的問題可能更明顯。最后,我們建議至少生成包含單元測試和集成測試的項目的覆蓋率報告。它有助于確定未經直接測試的區域,這些區域可以優先進行審查。
四. 有關依賴的說明
在諸如JavaScript和Rust的語言中,依賴項管理器內置了對依賴項審核的支持-掃描項目依賴項以查找已知存在漏洞的版本。在Go中,不存在這樣的工具,至少沒有處于公開可用且非實驗狀態的。
這種缺乏可能是由于存在多種不同的依賴關系管理方法:go-mod,go-get,vendored等。這些不同的方法使用根本不同的實現方案,導致無法直接識別依賴關系及其版本。此外,在某些情況下,開發人員通常會隨后修改其vendor的依賴的源代碼。
在Go的開發過程中,依賴管理問題的解決已經取得了進展,大多數開發人員都在朝使用go mod的方向發展。這樣就可以通過項目中的go.mod跟蹤和依賴項并進行版本控制,從而為以后的依賴項掃描工作打開了大門。我們可以在OWASP DependencyCheck工具中看到此類工作的示例,該工具是具有實驗性質的go mod插件。
五. 結論
最終,Go生態系統中有許多可以使用的工具。盡管大多數情況是完全不同的,但是各種靜態分析工具可幫助識別給定項目中的“懸而未決的問題”。當尋求更深層次的關注時,可以使用模糊測試,屬性測試和故障注入工具。編譯器配置隨后增強了動態技術,使構建測試夾具和評估其有效性變得更加容易。
本文翻譯自“Security assessment techniques for Go projects”。
推薦閱讀
持續集成和部署如何做?一步步教你在k8s上安裝Jenkins
如何把應用程序遷移到 K8S?
喜歡本文的朋友,歡迎關注“Go語言中文網”:
創作挑戰賽新人創作獎勵來咯,堅持創作打卡瓜分現金大獎總結
以上是生活随笔為你收集整理的go 根据输入类型执行对应的方法_安全很重要:Go项目的安全评估技术的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 直接内存与元空间_深入浅出 JVM 内存
- 下一篇: 修改服务器时间报错,修改服务器时间lin