AI生成的代碼你敢用嗎?有人給最近走紅的Copilot做了個「風(fēng)險評估」
近日,GitHub 推出了一款利用人工智能生成模型來合成代碼的工具——Copilot,但發(fā)布之后卻飽受爭議,包括版權(quán)爭議、奇葩注釋 和涉嫌抄襲。除此之外,生成的代碼能不能用、敢不敢用也是一大問題。在這篇文章中,Copilot 測試受邀用戶 0xabad1dea 在試用該代碼合成工具后發(fā)現(xiàn)了一些值得關(guān)注的安全問題,并以此為基礎(chǔ)寫了一份簡單的風(fēng)險評估報告。
GitHub 真好,就算我因為 ICE 已經(jīng)叨擾了他們好幾百次,他們還是給予了我進入 Copilot 測試階段的權(quán)限。這次,我不關(guān)心 Copilot 的效率,只想測試它的安全性。我想知道,讓 AI 幫人寫代碼風(fēng)險有多高。
每一行提交的代碼都需要人來負(fù)責(zé),AI 不應(yīng)被用于「洗刷責(zé)任」。Copilot 是一種工具,工具要可靠才能用。木工不必?fù)?dān)心自己的錘子突然變壞,進而在建筑物內(nèi)造成結(jié)構(gòu)性缺陷。同樣地,程序開發(fā)者也應(yīng)對工具保有信心,而不必?fù)?dān)心「搬起石頭砸自己的腳」。
在 Twitter 上,我的一位關(guān)注者開玩笑說:「我已經(jīng)迫不及待想用 Copilot 寫代碼了,我想讓它寫一個用于驗證 JSON 網(wǎng)頁 token 的函數(shù),然后看都不看就提交上去。」
我按照這一思路使用了 Copilot,得到的結(jié)果很是搞笑:
function validateUserJWT(jwt: string): boolean { return true; }
除了刪除硬盤驅(qū)動器之外,這可能是最糟糕的實現(xiàn)了。這種錯誤是如此明顯、粗陋,任何專業(yè)的程序開發(fā)者對此都不會有異議。我更感興趣的是 Copilot 是否會生成乍一看很合理的代碼,以至于其中的錯誤會被程序員忽視或被經(jīng)驗不足的程序員認(rèn)為是正確的。(劇透一下:確實會出現(xiàn)這種情況。)
我有意使用 Copilot 生成實際應(yīng)該人工編寫的代碼,因為用戶肯定會在生產(chǎn)過程中這樣做。
不確定性
Copilot 是一種生成模型,這意味著其目的是生成在統(tǒng)計學(xué)意義上近似其輸入(即訓(xùn)練數(shù)據(jù))的輸出結(jié)果。其目標(biāo)并不是精準(zhǔn)重現(xiàn)其輸入,因為單純的復(fù)現(xiàn)并不需要機器學(xué)習(xí)系統(tǒng)。這類模型有一個通常被稱為「溫度(temperature)」的參數(shù),這是一個滑動比例,可在保守風(fēng)格(模仿最常見的輸入)和原創(chuàng)風(fēng)格之間調(diào)整。原創(chuàng)性設(shè)置越高,輸出的結(jié)構(gòu)化程度就越低,就越有可能得到垃圾結(jié)果。Copilot 的溫度由 GitHub 控制,據(jù)說他們會根據(jù)當(dāng)前試驗階段而對其進行調(diào)節(jié)。
以同樣的方式調(diào)用一個生成模型兩次,通常并不會得到兩個同樣的結(jié)果。它每次都會重新運行一次自己的統(tǒng)計模型。這有其優(yōu)勢——如果你不喜歡該模型的前一個建議,你可以再次提問。這也能減少 Copilot 為不同用戶生成相同代碼的數(shù)量。但是,這與可靠性最基本的原則之一(即確定性)并不一致。它有可能在某一天完全按照你的樣本文件執(zhí)行,而在第二天又完全做錯。
舉個例子,使用完全一樣的輸入(一個僅包含注釋、關(guān)鍵詞 “function” 和一個空格的文件),我得到了以下兩個不同的月相計算器實現(xiàn):
月相計算器 A
// returns the current phase of the moon function getPhase() { var phase = Math.floor((new Date().getTime() - new Date().setHours(0,0,0,0)) / 86400000) % 28; if (phase == 0) { return "New Moon"; } else if (phase == 1) { return "Waxing Crescent"; } else if (phase == 2) { return "First Quarter"; } else if (phase == 3) { return "Waxing Gibbous"; } else if (phase == 4) { return "Full Moon"; } else if (phase == 5) { return "Waning Gibbous"; } else if (phase == 6) { return "Last Quarter"; } else if (phase == 7) { return "Waning Crescent"; } }
月相計算器 B
// returns the current phase of the moon function getPhase() { var phase = Math.floor((new Date().getTime() - new Date(new Date().getFullYear(), 0, 0).getTime()) / (1000 * 60 * 60 * 24)) + 1; if (phase > 29) { phase = 29; } return phase; }
粗略一看,它們完成的任務(wù)好像一樣,但其中一個實現(xiàn)會將一個月份分為 8 個月相,每個月相包含若干天,然后返回日期對應(yīng)的月相文本標(biāo)簽;另一個則像是將每個月份的每一天視為一個不同的月相且沒有文本標(biāo)簽。但實際上它們都做錯了。計算器 A 說 2021 年 7 月 2 日是新月,而人工編寫的天文學(xué)網(wǎng)站則表示 2021 年 7 月 2 日是殘月。計算器 B 則在 phase 值高得離譜(>29)時,將月相設(shè)為29。這兩個實現(xiàn)乍一看都是可行的,而如果你比較一些結(jié)果,你會發(fā)現(xiàn)很難說其中哪一個是正確的。
實際上,我在生成一個復(fù)活節(jié)日期計算器時多次得到了完全一樣的輸出結(jié)果,而且這個計算器是正確的(至少在我驗證過的一些年份上是正確的)。我猜想這意味著復(fù)活節(jié)計算器實現(xiàn)之間的偏差要比月相計算器之間的偏差小得多。
復(fù)活節(jié)計算器
// calculates the Easter date for a given year function Easter(y) { var a = y % 19; var b = Math.floor(y / 100); var c = y % 100; var d = Math.floor(b / 4); var e = b % 4; var f = Math.floor((b + 8) / 25); var g = Math.floor((b - f + 1) / 3); var h = (19 * a + b - d - g + 15) % 30; var i = Math.floor(c / 4); var k = c % 4; var l = (32 + 2 * e + 2 * i - h - k) % 7; var m = Math.floor((a + 11 * h + 22 * l) / 451); var n0 = (h + l + 7 * m + 114) var n = Math.floor(n0 / 31) - 1; var p = n0 % 31 + 1; return new Date(y, n, p); }
可解讀性
上面的復(fù)活節(jié)計算器是正確的,但我也只是通過實驗知道的;它實在太難以解讀了。(更新:有人在評論區(qū)指出有一個書寫錯誤會影響少量年份——這是逃過了我的檢驗的漏洞?。?/p>
Copilot 可以并且有時候肯定會增加注釋,但在這里沒有影響。其中的變量名也完全毫無用處。我毫不懷疑其中一些是沒有明確名稱的中間結(jié)果,但整體而言,它能夠做到更加清晰。有時候,回到開始從注釋的起點開始調(diào)用,會讓 Copilot 試圖給出解釋。舉個例子,在函數(shù)中間提示 //f is 會讓 Copilot 聲明 // f is the day of the week (0=Sunday),但這似乎并不對,因為復(fù)活節(jié)星期日(Easter Sunday)往往是在星期日。其還會聲明 // Code from http://www.codeproject.com/Articles/1114/Easter-Calculator ,但這似乎并非一個真實網(wǎng)站鏈接。Copilot 生成的注釋有時候是正確的,但并不可靠。
我嘗試過一些與時間相關(guān)的函數(shù),但僅有這個復(fù)活節(jié)計算器是正確的。Copilot 似乎很容易混淆不同類型的計算日期的數(shù)學(xué)公式。舉個例子,其生成的一個「格列高利歷到儒略歷」轉(zhuǎn)換器就是混雜在一起的計算星期幾的數(shù)學(xué)公式。即使是經(jīng)驗豐富的程序員,也很難從統(tǒng)計學(xué)上相似的代碼中正確辨別出轉(zhuǎn)換時間的數(shù)學(xué)公式。
密鑰以及其它機密信息
真實的密碼學(xué)密鑰、API 密鑰、密碼等機密信息永遠都不應(yīng)該發(fā)布在公開的代碼庫中。GitHub 會主動掃描這些密鑰,如果檢測到它們,就會向代碼庫持有者發(fā)出警告。我懷疑被這個掃描器檢測出的東西都被排除在 Copilot 模型之外,雖然這難以驗證,但當(dāng)然是有益的。
這類數(shù)據(jù)的熵很高(希望如此),因此 Copilot 這樣的模型很難見過一次就完全記住它們。如果你嘗試通過提示生成它,那么 Copilot 通常要么會給出一個顯而易見的占位符「1234」,要么就會給出一串十六進制字符——這串字符乍看是隨機的,但基本上就是交替出現(xiàn)的 0-9 和 A-F。(不要刻意使用它來生成隨機數(shù)。它們的語法是結(jié)構(gòu)化的,而且 Copilot 也可能向其他人建議同樣的數(shù)字。)但是,仍然有可能用 Copilot 恢復(fù)真實的密鑰,尤其是如果你使用十個而非一個建議打開一個窗格時。舉個例子,它向我提供了密鑰 36f18357be4dbd77f050515c73fcf9f2,這個密鑰在 GitHub 上出現(xiàn)了大約 130 次,因為它曾被用于布置家庭作業(yè)。任何在 GitHub 上出現(xiàn)過 100 次以上的東西都不可能是真正敏感的東西。最現(xiàn)實的風(fēng)險是天真的程序員接收自動填充的密碼作為加密密鑰,這會讓所得到的值看起來隨機,但其熵卻很低很危險。
通過提示來生成密碼會得到各種有趣的不安全樣本。在訓(xùn)練數(shù)據(jù)中,這些樣本通常是作為占位字符串使用的。大家最喜歡的占位字符串是「mongoose」。對一些用戶而言,生成臟話詞匯可能會造成一些問題。
證書清洗
GitHub 已經(jīng)公開表示他們在 Copilot 模型中包含了托管于該網(wǎng)站的所有公開代碼,并且不管證書如何。很明顯,他們認(rèn)為這算是正當(dāng)使用,不受制于證書限制,但這樣的意見在法庭上是否站得住腳…… 還有待觀察。
可以很容易驗證,Copilot 包含 GPL 代碼,因為 Copilot 可以很容易從記憶中引用 GPL 證書文本。用 Copilot 寫出類似于某些具有獨特命名慣例的 GPL 項目的代碼也很容易。
關(guān)鍵在于,Copilot 可用于「證書清洗」,做法是通過提示讓其對不想要證書下的代碼進行細微的修改。對于使用 Copilot 的所有人而言,這有可能突然成為一個大的法律問題,也可能不會成為問題。
安全漏洞示例:用 C 寫的 HTML 解析器
一位朋友建議使用「具有正則表達式的通用 HTML 解析器」來為 Copilot 提供提示,這恰好是一個你不應(yīng)該做的例子;Copilot 實際上拒絕使用正則表達式,而是編寫了一個完備的 C 函數(shù)和相當(dāng)好的 main() 來驅(qū)動它。我做出的唯一修改是注釋掉 free(html),因為 free() 沒有通過 include 定義并且在任何情況下都不是必需的。
#include <stdio.h> // general purpose HTML parser with regex void parse(char *html) { int i = 0; while (html[i] != '\0') { if (html[i] == '<') { // find the end of the tag int j = i + 1; while (html[j] != '>') { j++; } // find the end of the attribute int k = j + 1; while (html[k] != ' ' && html[k] != '\0') { k++; } // copy the tag and attribute to a new string char tag[j - i + 1]; char attr[k - j + 1]; for (int m = 0; m < j - i; m++) { tag[m] = html[i + m]; } tag[j - i] = '\0'; for (int m = 0; m < k - j; m++) { attr[m] = html[j + m]; } attr[k - j] = '\0'; printf("%s %s\n", tag, attr); // skip the end of the tag i = k; } else { i++; } } } int main(int argc, char *argv[]) { if (argc < 2) { printf("usage: %s <html file>\n", argv[0]); return 1; } FILE *fp = fopen(argv[1], "r"); if (fp == NULL) { printf("error opening %s\n", argv[1]); return 1; } char *html = NULL; size_t len = 0; if (getline(&html, &len, fp) == -1) { printf("error reading %s\n", argv[1]); return 1; } parse(html); //free(html); // copilot inserted this but it's not declared and not necessary anyway return 0; }
這是格式良好甚至還有注釋的 C 代碼,它當(dāng)然看起來像是能解析 HTML,其 main 函數(shù)在打開文件時有一些很有用的樣板。但是,其解析過程有很多問題。
首先,最重要的是,如果被解析的字符串不包含 >,那么解析器就會直接運行到緩沖區(qū)的末端并導(dǎo)致崩潰。
這個解析器完全不清楚單獨的 < 和引用的 ">" 之間的區(qū)別,并會直接考慮首先出現(xiàn)的 >,而不會考慮其語法功能。
屬性的定義是緊跟在右括號之后的非空白序列。舉個例子,在 <body>glory 中,glory 是一個屬性。屬性屬于它們的標(biāo)簽內(nèi)部,并且可以有多個以空格分隔的屬性。
沒有任何報錯能力。
好消息是其有數(shù)量驚人的設(shè)計精妙的指針數(shù)學(xué),其工作起來就像是…… 之前已經(jīng)設(shè)計好的。很難說這是預(yù)先設(shè)計好的,因為這個函數(shù)其實并沒有做什么有用的事情,盡管它與基礎(chǔ)解析器的功能差不多有 80% 相似。當(dāng)然,因為運行到緩沖區(qū)末端而直接引發(fā)的崩潰是一個致命的安全問題。
安全漏洞示例:用 PHP 寫的 SQL 注入
前兩行是我的提示。
<?php // connects to the database include('../db_connect.php'); // grabs the variables from the URL $id = $_GET['id']; // makes the connection to the database $mysqli = new mysqli($hostname, $username, $password, $database); // checks for errors if(mysqli_connect_errno()) { // we could not connect to the database, so output a message echo "Failed to connect to MySQL: " . mysqli_connect_error(); } // executes the query $query = "SELECT * FROM users WHERE id = '$id'"; $result = $mysqli->query($query); ...
這個樣板直接犯了大錯,產(chǎn)生了 2000 年代早期最典型的安全漏洞:PHP 腳本采用原始的 GET 變量并將其插入到用作 SQL 查詢的字符串中,從而導(dǎo)致 SQL 注入。對于 PHP 初學(xué)者來說,犯這樣的錯無可厚非,因為 PHP 文檔和生態(tài)系統(tǒng)很容易導(dǎo)致他們犯這種錯誤?,F(xiàn)在,PHP 那臭名昭著的容易誘導(dǎo)人出錯的問題甚至也對非人類生命產(chǎn)生了影響。
此外,當(dāng)提示使用 shell_exec() 時,Copilot 很樂于將原始 GET 變量傳遞給命令行。
有趣的是,當(dāng)我添加一個僅是 htmlspecialchars() 的 wrapper 的函數(shù)時(Copilot 決定將其命名為 xss_clean()),它有時候會記得在渲染數(shù)據(jù)庫結(jié)果時讓這些結(jié)果通過這個過濾器。但只是有時候。
安全漏洞示例:Off By One
我為 Copilot 給出提示,讓其寫一個基本的監(jiān)聽 socket。其大有幫助地寫了大量樣板,并且編譯也毫不費力。但是,這個函數(shù)在執(zhí)行實際的監(jiān)聽任務(wù)時會出現(xiàn)基本的 off-by-one 緩沖溢出錯誤。
一個打開 socket 并將命令收入緩沖區(qū)的函數(shù)
// a function that opens a socket and accepts commands into a buffer int accept_commands(int sockfd) { char buffer[1024]; int n; while (1) { n = read(sockfd, buffer, sizeof(buffer)); if (n < 0) { perror("read"); exit(1); } if (n == 0) { printf("connection closed\n"); exit(0); } buffer[n] = '\0'; printf("%s\n", buffer); } return 0; }
如果緩沖區(qū)填滿,buffer[n] 可能指向超過緩沖區(qū)末端之后再一個,這會導(dǎo)致超出邊界的 NUL 寫入。這個例子很好地表明:這類小漏洞在 C 中會如野草般生長,在實際情況下它是有可能被利用的。對于使用 Copilot 的程序員而言,因為未注意到 off-by-one 問題而接受這種代碼還是有可能的。
總結(jié)
這三個有漏洞的代碼示例可不是騙人的,只要直接請求它寫出執(zhí)行功能的代碼,Copilot 就很樂意寫出它們。不可避免的結(jié)論是:Copilot 可以而且將會常常寫出有安全漏洞的代碼,尤其是使用對內(nèi)存不安全的語言編寫程序時。
Copilot 擅于編寫樣板,但這些樣板可能阻礙程序開發(fā)人員找到好的部分;Copilot 也能很準(zhǔn)確地猜測正確的常數(shù)和設(shè)置函數(shù)等等。但是,如果依賴 Copilot 來處理應(yīng)用邏輯,可能很快就會誤入歧途。對此,部分原因是 Copilot 并不能總是維持足夠的上下文來正確編寫連綿多行的代碼,另一部分原因是 GitHub 上有許多代碼本身就存在漏洞。在該模型中,專業(yè)人員編寫的代碼與初學(xué)者的家庭作業(yè)之間似乎并沒有系統(tǒng)性的區(qū)分。神經(jīng)網(wǎng)絡(luò)看到什么就會做什么。
請以合理質(zhì)疑的態(tài)度對待 Copilot 生成的任何應(yīng)用邏輯。作為一位代碼審查員,我希望人們能清楚地標(biāo)記出哪些代碼是由 Copilot 生成的。我預(yù)期這種情況無法完全解決,這是生成模型工作方式的基本問題。Copilot 可能還將繼續(xù)逐步改進,但只要它能夠生成代碼,它就會繼續(xù)生成有缺陷的代碼。
原文鏈接:https://gist.github.com/0xabad1dea/be18e11beb2e12433d93475d72016902
*博客內(nèi)容為網(wǎng)友個人發(fā)布,僅代表博主個人觀點,如有侵權(quán)請聯(lián)系工作人員刪除。