SQL注入:使用預編譯防御SQL注入時產生的問題

目錄

前言

模擬預編譯

真正的預編譯

預編譯中存在的SQL注入

寬字節

沒有進行參數綁定

無法預編譯的位置


前言

相信學習過SQL注入的小伙伴都知道防御SQL注入最好的方法,就是使用預編譯也就是PDO是可以非常好的防御SQL注入的,但是如果錯誤的設置了PDO,即使是預編譯也存在注入的可能,那么下面我會參考大佬的文章來學習+復現一下如何正確的使用預編譯防御SQL注入

首先是第一個問題,為什么預編譯或者說參數化查詢可以防止sql注入呢?

使用參數化查詢數據庫服務器不會把參數的內容當作 sql 指令的一部分來執行,是在數據庫完成 sql 指令的編譯后才套用參數運行。

簡單的說: 參數化能防注入的原因在于,語句是語句,參數是參數,參數的值并不是語句的一部分,數據庫只按語句的語義跑 。

SQL注入產生的原因是因為服務器錯誤把用戶的輸入當作了執行的語句

假設有一個sql語句是這樣的:

select username from test where id = $_POST[id]

如果用戶正常輸入1,語句則為:

select username from test where id = 1

那么顯然查詢出來的就只會是test表中id為1的那個username,然而如果用戶不按照開發者期待的南陽,輸入的是1 union select version(),那么語句就變為了:

select username from test where id = 1 union select version()

最后查詢出來的就會使id=1的那個username以及數據庫的版本,這是因為本來理論上查詢的應該是id為”1 union select version()”的這個用戶,而數據庫執行語句的時候把它分開了,視作了查詢select username from test where id = 1以及select version()。

預編譯的原理:如果源碼這里提前對$_POST[id]進行了處理,那么數據庫相當于會提前對整個語句進行編譯,把它編譯成select username from test where id = 用戶輸入

因此整個語句的功能已經提前定死了,就是查詢id = 用戶輸入的username,不再會像之前一樣錯誤理解成查詢id=1的用戶然后再查詢版本,這樣看來預編譯的作用,就是消除了sql語句的歧義。

那么回看最初我們提出的疑問,預編譯真的能完美防御sql注入嗎?有沒有什么奇技淫巧能繞過預編譯進行注入呢?

有一篇大佬的文章分析過:預編譯真的能完美防御SQL注入嗎?

這里面提到一個很有趣的點——預編譯是將sql語句參數化,剛剛的例子中 where語句中的內容是被參數化的。這就是說,預編譯僅僅只能防御住可參數化位置的sql注入。那么,對于不可參數化的位置,預編譯將沒有任何辦法。

那么哪些是不可參數化的位置呢,原作者說:

img?

為了研究原理,大佬又找到了一篇文章,這個應該是最早提出order by后沒法參數化所以可以被sql注入的?SQL預編譯中order by后為什么不能參數化原因,文章里是這么解釋的

大概就是說,order by后面的字段是不能加引號的,而預編譯后會自動加上引號,因為這個矛盾所以order by的后面不能進行預編譯。

不過當時他解釋原因是因為自動加引號的setString()方法,而這個方法似乎只是java下存在的,而這篇文章是從原理出發研究研究php下的注入可能(其實這種思路不同語言是共通的)

模擬預編譯

后端頁面代碼:

<?php
$username = $_POST['username']; // 接收username
# 建立數據庫連接
header("Content-Type:text/html;charset=utf-8");
$dbs = "mysql:host=127.0.0.1;dbname=sort";
$dbname = "root";
$passwd = "root";
// 創建連接,選擇數據庫,檢測連接
try{$conn = new PDO($dbs, $dbname, $passwd);echo "連接成功<br/>";
}
catch (PDOException $e){die ("Error!: " . $e->getMessage() . "<br/>");
}
# 設置預編譯語句,綁定參數,這里使用命名占位符
$stmt = $conn->prepare("select fraction from fraction where name = :username");
$stmt->bindParam(":username",$username);
$stmt->execute();
if($fraction = $stmt->fetch(PDO::FETCH_ASSOC)){echo '查詢成功';echo '<br/>';echo '學生:'.$username;echo '<br/>';# echo '分數:'.$fraction;print_r("分數".$fraction[fraction]);
}
else{
}
$conn=null; # 關閉鏈接
?>

執行查詢name="mechoy",查看數據庫日志:

27 Connect  root@localhost on sort using TCP/IP ? ? ? ? # 建立連接
27 Query ? ?select fraction from fraction where name = 'mechoy' # 執行查詢
27 Quit ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? # 結束

從日志來看,沒有prepare和execute,只是執行了一個查詢的SQL語句,并沒有進行預編譯。

顯然,PDO默認情況下使用的是模擬預編譯。

模擬預編譯是防止某些數據庫不支持預編譯而設置的(如sqllite與低版本MySQL)。

如果模擬預處理開啟,那么客戶端程序內部會模擬MySQL數據庫中的參數綁定這一過程。

也就是說,程序會在內部模擬prepare的過程,當執行execute時,再將拼接后的完整SQL語句發送給MySQL數據庫執行。

而想要真正使用預編譯,首先需要數據庫支持預編譯,再在代碼中加入

$conn -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
# bool PDO::setAttribute ( int $attribute , mixed  $value ) 設置數據庫句柄屬性。
# PDO::ATTR_EMULATE_PREPARES 啟用或禁用預處理語句的模擬。 有些驅動不支持或有限度地支持本地預處理。使用此設置強制PDO總是模擬預處理語句(如果為 TRUE  ),或試著使用本地預處理語句(如果為 FALSE )。如果驅動不能成功預處理當前查詢,它將總是回到模擬預處理語句上。需要 bool  類型。 
#這里在PHP5.2.17時無效,暫未找到原因
#更改版本為PHP5.6.9時生效

再執行查詢name="mechoy",查看數據庫日志:

4 Connect ? root@localhost on sort using TCP/IP
4 Prepare ? select fraction from fraction where name = ?
4 Execute ? select fraction from fraction where name = 'mechoy'
4 Close ? ? stmt ? ?
4 Quit
# 可以看到當PDO::ATTR_EMULATE_PREPARES設置為false時,取消了模擬預處理,采用本地預處理

也可以使用下列這種方式:
后端代碼:

<?php
$username = $_POST['username'];$db = new PDO("mysql:host=localhost;dbname=test", "root", "root");$stmt = $db->prepare("SELECT password FROM test where username= :username");$stmt->bindParam(':username', $username);$stmt->execute();$result = $stmt->fetchAll(PDO::FETCH_ASSOC);var_dump($result);$db = null;?>

?然后我們使用Firefox瀏覽器POST方式提交一個數據:
??

不出意外的查出了值,我們去日志看看預編譯對我們傳入的值做了什么處理:

2023-10-22T12:59:55.149736Z  ?  5 Connect   root@localhost on test using TCP/IP
2023-10-22T12:59:55.149993Z  ?  5 Query SELECT password FROM test where username= 'root'
2023-10-22T12:59:55.150987Z  ?  5 Quit

只有connect query 然后就quit,你可能會奇怪,我們不是綁定了參數然后預編譯了嗎,怎么感覺和正常的sql語句邏輯差不多呢,我們再post一個’root’試試:

這次竟然啥也沒查出來,到底是怎么回事!我們去日志看看:

2023-10-22T13:12:13.619712Z  ?  9 Connect   root@localhost on test using TCP/IP
2023-10-22T13:12:13.619960Z  ?  9 Query SELECT password FROM test where username= '\'admin\''
2023-10-22T13:12:13.620931Z  ?  9 Quit  

這次你肯定恍然大悟了,為什么默認的預編譯模式模擬預編譯被稱作虛假的預編譯,因為他在sql執行的過程中其實根本沒有參數綁定、預編譯的過程,本質上只是對符號做了過濾

比如假如我們輸入注入語句root’ union select database()#,日志里的數據為:

2023-10-22T15:34:50.356115Z  ? 11 Connect   root@localhost on test using TCP/IP
2023-10-22T15:34:50.356353Z  ? 11 Query SELECT password FROM test where username= 'admin\' union select database()#'
2023-10-22T15:34:50.357303Z  ? 11 Quit  

那為什么開發者要做一個虛假的預編譯呢,那是因為一個參數——PDO::ATTR_EMULATE_PREPARES,這個選項用來配置PDO是否使用模擬預編譯,默認是true,因此默認情況下PDO采用的是模擬預編譯模式,設置成false以后,才會使用真正的預編譯。

開啟這個選項主要是用來兼容部分不支持預編譯的數據庫(如sqllite與低版本MySQL),對于模擬預編譯,會由客戶端程序內部參數綁定這一過程(而不是數據庫),內部prepare之后再將拼接的sql語句發給數據庫執行。

真正的預編譯

使用下列代碼就是使用的真正的預編譯了

<?php
$username = $_POST['username'];$db = new PDO("mysql:host=localhost;dbname=test", "root", "root");
$db -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);$stmt = $db->prepare("SELECT password FROM user where username= :username");$stmt->bindParam(':username', $username);$stmt->execute();$result = $stmt->fetchAll(PDO::FETCH_ASSOC);var_dump($result);$db = null;?>

我們再次使用上面的那種方式來使用POST查詢一下:

?可以看到這里的執行結果是和上面的模擬預編譯的結果是一樣的,那么再來看看日志:

231018 23:51:17	   61 Connect	root@localhost on test61 Prepare	SELECT password FROM test where username= ?61 Execute	SELECT password FROM test where username= 'admin'

這時數據庫中執行的順序變成了:先連接,然后準備語句,用問號?占位,接著用輸入替換問號?執行語句,專業點的說法叫做:

  1. 建立連接;

  2. 構建語法樹;

  3. 執行

這也是為什么我們之前說的,預編譯的作用是讓整個語句的功能已經提前定死,消除了sql語句的歧義。

當我們輸入username= ‘admin’同樣會沒有任何輸出

那么再來看看日志:

我們看一下數據庫的日志:

2023-10-22T15:49:30.089718Z  ? 24 Connect   root@localhost on test using TCP/IP
2023-10-22T15:49:30.089986Z  ? 24 Prepare   SELECT password FROM test where username= ?
2023-10-22T15:49:30.090041Z  ? 24 Execute   SELECT password FROM test where username= '\'admin\''

這時我們再輸入注入語句root' union select database()#

2023-10-22T15:43:23.500819Z  ? 17 Connect   root@localhost on test using TCP/IP
2023-10-22T15:43:23.502097Z  ? 17 Prepare   SELECT password FROM test where username= ?
2023-10-22T15:43:23.502165Z  ? 17 Execute   SELECT password FROM test where username= 'admin\' union select database()#'
2023-10-22T15:43:23.502600Z  ? 17 Close stmt    
2023-10-22T15:43:23.502627Z  ? 17 Quit  

分析預編譯的原理其實可以發現,預編譯其實是為了提高MySQL的運行效率而誕生(而不是為了防止sql注入),因為它可以先構建語法樹然后帶入查詢參數,避免了一次執行一次構建語法樹的繁瑣,對于數據量以及查詢量較大的數據庫能極大提高運行效率。

從原理出發,可以看出來有些方面預編譯并不能完全阻止預編譯。

預編譯中存在的SQL注入

寬字節

寬字節注入出現的本質就是因為數據庫的編碼與代碼的編碼不同,導致用戶可以通過輸入精心構造的數據通過編碼轉換吞掉轉義字符。

看我們剛剛sql語句的執行日志可以發現對于模擬預編譯理論上是存在寬字節注入的,因為它只是本地對執行的sql語句進行一次模擬的預編譯然后就把語句發給數據庫執行去了,而且只是使用了\來進行轉義,如果我們能有什么辦法吞掉這個\,那是不是我們就可以執行惡意的sql語句了呢

后端代碼如果為:

<?php
$username = $_POST['username'];$db = new PDO("mysql:host=localhost;dbname=test;charset=gbk", "root", "root");// $db -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);$db->query('SET NAMES GBK');$stmt = $db->prepare("SELECT password FROM test where username= :username");$stmt->bindParam(':username', $username);$stmt->execute();$result = $stmt->fetchAll(PDO::FETCH_ASSOC);var_dump($result);$db = null;?>

然后我們輸入一下內容就可以利用寬字節繞過預處理實現注入

username=admin%df%27%20union%20select%20database();#

通過抓包可以看到這里確實將轉義字符閉合為一個特殊字符了

這個語句在navicat里是能正常執行的,但我并沒有在網頁上獲得輸出,是因為預編譯只會輸出第一條語句,因此后面union的執行結果無法輸出

這里如果使用真預編譯就不會出現上面的這種問題

因此相比于模擬預編譯,真編譯的安全性大的多,現在可能的幾種針對預編譯的注入方法也都是在模擬預編譯下實現的。

沒有進行參數綁定

沒有參數綁定的預編譯等于沒有預編譯,無論是真編譯還是模擬預編譯,沒有參數綁定等于沒編譯,并且由于DPO默認支持堆疊注入,我們可以通過堆疊注入先插入值然后查詢插入的值獲取輸出結果。

后端代碼:

<?php
$id = $_POST['id'];$dbs = "mysql:host=localhost;dbname=test";
$dbname = "root";
$passwd = "root";$conn = new PDO($dbs, $dbname, $passwd);# 預處理語句
$stmt = $conn->prepare("SELECT * FROM test where id= $id");
$conn -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);var_dump($result);$conn=null; # 關閉鏈接
?>

可以看到代碼中的id沒有進行參數綁定

那么我們可以嘗試POST提交一下使用堆疊注入來將要查詢的東西插入到user表中的兩個字段中,然后在進行查詢:

然后再POST傳入的值:

然后訪問該id:

就可以看到對應插入的值了

無法預編譯的位置

上面提到過,order by的后面是沒法預編譯的,因此遇到可控排序功能一般一注一個準,下面我們來通過日志研究一下這到底是為什么

后端代碼:

<?php
$col = $_POST['col'];$dbs = "mysql:host=localhost;dbname=test";
$dbname = "root";
$passwd = "root";$conn = new PDO($dbs, $dbname, $passwd);
$conn -> setAttribute(PDO::ATTR_EMULATE_PREPARES, false);# 預處理語句(這里會自動加上單引號)
$stmt = $conn->prepare("SELECT * FROM user order by :col");$stmt->bindParam(':col', $col);
$stmt->execute();
$result = $stmt->fetchAll(PDO::FETCH_ASSOC);var_dump($result);$conn=null; # 關閉鏈接
?>

假如我們想按照password進行排序,post一個col=password

我們可以看看日志:

2023-10-27T01:23:43.100087Z   187 Connect   root@localhost on test using TCP/IP
2023-10-27T01:23:43.100579Z   187 Query SELECT * FROM test order by 'password'
2023-10-27T01:23:43.101405Z   187 Quit  

可以看到它自動給我們傳入的值password的加了引號,然而這其實是與我們的目標背道而馳的:

order by在底層查詢過程中是直接把order by后面這個值進行利用然后排序,如果加上引號的話數據庫會索引失敗,查詢結果其實等同于order by NULL或者order by TRUE,本質上是一條不合法的請求。

因此無論是order by還是group by,他們后面的參數都是不能帶引號的,而預編譯中參數綁定的過程會自動給它們帶上引號,這就導致這些位置上的參數是不能被預編譯的,因為它的執行結果是錯誤的。

所以滲透的時候遇到疑似排序的功能我們可以大膽的去嘗試sql注入,一般都能成功。

總而言之就一個思路,不能加引號的位置就不能預編譯。

這里我們就可以看出預編譯很明顯的缺陷,當然,我們也不能錯怪預編譯的設計者們,因為這玩意兒本來設計之初就不是給你防注入,是用來在大批量查詢時減少語法樹構造的,因此出現差錯也是可以理解的,當然這種差錯就給了黑客可乘之機。

參考鏈接:

奇安信攻防社區-SQL注入&預編譯 (butian.net)

預編譯與sql注入 – fushulingのblog

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/696919.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/696919.shtml
英文地址,請注明出處:http://en.pswp.cn/news/696919.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

計算機設計大賽 深度學習動物識別 - 卷積神經網絡 機器視覺 圖像識別

文章目錄 0 前言1 背景2 算法原理2.1 動物識別方法概況2.2 常用的網絡模型2.2.1 B-CNN2.2.2 SSD 3 SSD動物目標檢測流程4 實現效果5 部分相關代碼5.1 數據預處理5.2 構建卷積神經網絡5.3 tensorflow計算圖可視化5.4 網絡模型訓練5.5 對貓狗圖像進行2分類 6 最后 0 前言 &#…

從零學算法238

238.給你一個整數數組 nums&#xff0c;返回 數組 answer &#xff0c;其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘積 。 題目數據 保證 數組 nums之中任意元素的全部前綴元素和后綴的乘積都在 32 位 整數范圍內。 請 不要使用除法&#xff0c;且在 O(n) 時間復…

Python自動化UI測試之Selenium基礎實操

1. Selenium簡介 Selenium 是一個用于 Web 應用程序測試的工具。最初是為網站自動化測試而開發的&#xff0c;可以直接運行在瀏覽器上&#xff0c;支持的瀏覽器包括 IE&#xff08;7, 8, 9, 10, 11&#xff09;&#xff0c;Mozilla Firefox&#xff0c;Safari&#xff0c;Googl…

SVN忽略已提交的文件(ignore,移出版本控制)

本文適用于已安裝TortoiseSVN客戶端的同學。 1、右鍵點擊要忽略的文件夾或文件&#xff0c;鼠標移到“TortoiseSVN”&#xff0c;找到“Unversion and add to ignore list”&#xff0c;選擇文件夾&#xff0c;彈出提示框確認忽略。 2、設置完忽略文件后&#xff0c;還需要做…

多維時序 | Matlab實現GRU-MATT門控循環單元融合多頭注意力多變量時間序列預測模型

多維時序 | Matlab實現GRU-MATT門控循環單元融合多頭注意力多變量時間序列預測模型 目錄 多維時序 | Matlab實現GRU-MATT門控循環單元融合多頭注意力多變量時間序列預測模型預測效果基本介紹程序設計參考資料 預測效果 基本介紹 1.多維時序 | Matlab實現GRU-MATT門控循環單元融…

【Maven】介紹、下載及安裝、集成IDEA

目錄 一、什么是Maven Maven的作用 Maven模型 Maven倉庫 二、下載及安裝 三、IDEA集成Maven 1、POM配置詳解 2、配置Maven環境 局部配置 全局設置 四、創建Maven項目 五、Maven坐標詳解 六、導入Maven項目 方式1&#xff1a;使用Maven面板&#xff0c;快速導入項目 …

React Native框架開發介紹,以及其優點

大家好&#xff0c;我是咕嚕鐵蛋&#xff0c;在今天的文章中&#xff0c;我通過科技手段和大家一起探討一下React Native框架的開發介紹以及其優點。我深知選擇合適的開發工具對于項目的成功至關重要。而React Native作為一款流行的跨平臺移動應用開發框架&#xff0c;其獨特之…

Linux并發與競爭的基本概念

一. 簡介 Linux是一個多任務操作系統&#xff0c;肯定會存在多個任務共同操作同一段內存或者設備的情況&#xff0c; 多個任務甚至中斷都能訪問的資源叫做共享資源&#xff0c;在驅動開發中要注意對共享資源的保護&#xff0c;也就是要處理對共享資源的并發訪問。比如&#xf…

【服務器數據恢復】FreeNAS+ESXi虛擬機數據恢復案例

服務器數據恢復環境&#xff1a; 一臺服務器通過FreeNAS&#xff08;本案例使用的是UFS2文件系統&#xff09;實現iSCSI存儲&#xff0c;整個UFS2文件系統作為一個文件掛載到ESXi虛擬化系統&#xff08;安裝在另外2臺服務器上&#xff09;上。該虛擬化系統一共有5臺虛擬機&…

2024水科技大會暨技術裝備成果展覽會——高品質供水和飲用水水源安全保障論壇

供水與飲水安全直接關系到人民群眾的生活與健康&#xff0c;切實做好城市供水與飲水安全保障工作&#xff0c;是把以人為本真正落到實處的一項緊迫任務。近年來&#xff0c;中央和地方加大了城鄉供水與飲水安全保障工作的力度&#xff0c;對標最優質供水城市建設要求&#xff0…

[Angular 基礎] - service 服務

[Angular 基礎] - service 服務 之前的筆記就列舉三個好了……沒想到 Angular 東西這么多(&#xff70; &#xff70;;)……全加感覺越來越湊字數了 [Angular 基礎] - 視圖封裝 & 局部引用 & 父子組件中內容傳遞 [Angular 基礎] - 生命周期函數 [Angular 基礎] - 自…

請簡述你對SpringMVC的理解

SpringMVC是一種基于Java語言開發&#xff0c;實現了WebMVC設計模式&#xff0c;請求驅動類型 的輕量級Web框架。 采用了MVC架構模式的思想&#xff0c;通過把Model&#xff0c;View&#xff0c;Controller分離&#xff0c;將Web層進 行職責解耦&#xff0c;從而把復雜的Web應…

idea打開項目白屏

解決方法&#xff1a; 右鍵“最大化” idea打開項目白板解決方案_idea打開白屏-CSDN博客 IDEA 2022 CPU占用100%的問題及解決方法_java_腳本之家

STM32控制數碼管從0顯示到99

首先 先畫電路圖吧&#xff01;打開proteus&#xff0c;導入相關器件&#xff0c;繪制電路圖。如下&#xff1a;&#xff08;記得要保存啊&#xff01;發現模擬一遍程序就自動退出了&#xff0c;有bug&#xff0c;我是解決不了&#xff0c;所以就是要及時保存&#xff0c;自己重…

計算機組成原理(10)----微程序控制器

目錄 1.微程序控制器的設計思想 2.微指令的基本格式 3.微程序控制器的基本結構 &#xff08;1&#xff09;控制存儲器CM &#xff08;2&#xff09;CMAR &#xff08;3&#xff09;地址譯碼 &#xff08;4&#xff09;CMDR &#xff08;5&#xff09;微地址形成部件 &…

31.云原生Istio可觀測性之官網Bookinfo應用實戰演示

云原生專欄大綱 文章目錄 可觀測性kiali介紹Overview&#xff08;概觀&#xff09;Application&#xff08;應用維度&#xff09;workloads&#xff08;負載維度&#xff09;Services&#xff08;服務維度&#xff09;Istio Config&#xff08;配置維度&#xff09; Kiali部署…

音頻聲波的主觀感受

一、響度 聲壓是“客觀”的&#xff0c;響度是“主觀”的。 響度又稱音量。人耳感受到的聲音強弱&#xff0c;它是人對聲音大小的一個主觀感覺量。響度的大小決定于聲音接收處的波幅&#xff0c;就同一聲源來說&#xff0c;波幅傳播的愈遠&#xff0c;響度愈小…

React18原理: React核心對象之Update、UpdateQueue、Hook、Task對象

Update 與 UpdateQueue 對象 1 ) 概述 在fiber對象中有一個屬性 fiber.updateQueue是一個鏈式隊列&#xff08;即使用鏈表實現的隊列存儲結構&#xff09;是和頁面更新有關的 2 &#xff09;Update對象相關的數據結構 // https://github.com/facebook/react/blob/v18.2.0/pa…

【Nginx】Nginx配置反向代理 和 https

nginx.conf配置 進入linux /etc/nginx/ 打開nginx.conf 進行以下配置 http {include mime.types;default_type application/octet-stream;sendfile on;keepalive_timeout 65;server {#監聽443端口listen 443 ssl;#你的域名server_name huiblog.top;#ssl證書的pe…

VSCode The preLaunchTask ‘C/C++: clang++ 生成活動文件‘ terminated with exit code -1

更改tasks.json文件里面的type為shell 選擇g 選擇g&#xff0c;然后點回到text.c&#xff0c;按下F5. 得到結果。 文中內容參考: 從零開始手把手教你配置屬于你的VS Code_嗶哩嗶哩_bilibili https://blog.csdn.net/qq_63872647/article/details/128006861