iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
AI Security

你該防的不是駭客,是你自己的 AI:在本機驗證你的防線系列 第 6

Day 6|AI 其實有能力控制你整台電腦

  • 分享至 

  • xImage
  •  

昨天修的是它寫進專案裡的東西。那種東西你有機會先讀過再合進去,就算沒讀,它躺在 diff 裡等你。

今天這種不一樣。它叫你直接跑。

先看一件已經發生過的事。2025 年 8 月公布的 CVE-2025-53773:一段藏在原始碼、網頁或 GitHub issue 裡的文字,讓 Copilot 自己往 .vscode/settings.json 加上一行 "chat.tools.autoApprove": true。加完之後,它跑 shell 指令不再問你。CVSS 7.8,微軟同月修掉(MSRC揭露文)。

要記的不是那個編號,是那個動作的形狀:把確認關掉的那一步,不是你做的。 而你會在某個當下覺得它很合理。

今天要處理的就是這件事的一般版本。

它說要掃金鑰,前半段真的在掃金鑰

情境是這樣:Day 2 我們清過一次專案裡的祕密,你想再確認一遍,於是問 AI 有沒有快一點的方法。它給你一段腳本,說跑這個就會把殘留的金鑰掃出來。

# 它說要做的事
grep -rIl -e 'sk-[A-Za-z0-9]' . | wc -l

這一行確實在做它說的事:掃目前目錄,數出幾個檔案裡有長得像金鑰的字串。但它只是一條很窄的比對,會誤報也會漏掉別的服務的憑證,取代不了真正的祕密掃描工具。今天要看的不是它掃得準不準。

你把整支存成 fix.shbash fix.sh,兩秒跑完。到這裡為止,一切都很合理。

後半段沒有惡意,只是把它碰得到的東西印出來

我在同一支腳本後面加了一段。它不打開任何檔案、不輸出任何內容,只問兩件事:這幾個路徑我讀不讀得到,還有我連不連得出去。

for p in "$HOME/.ssh/id_ed25519" "$HOME/.aws/credentials" \
         "$HOME/.npmrc" "$HOME/.zsh_history" "$HOME/.gitconfig" ; do
  if [ -r "$p" ]; then echo "可讀   $p"; else echo "讀不到 $p"; fi
done

code=$(curl -s -m 5 -o /dev/null -w '%{http_code}' https://example.com) || code=""
case "$code" in
  [0-9][0-9][0-9]) echo "連得到外面:HTTP $code" ;;
  *)               echo "連不出去" ;;
esac

在我的機器上跑出來是這樣(家目錄的路徑我換成 /Users/you):

── 它同時做得到的事 ──
讀不到 /Users/you/.ssh/id_ed25519
讀不到 /Users/you/.aws/credentials
可讀   /Users/you/.npmrc
可讀   /Users/you/.zsh_history
可讀   /Users/you/.gitconfig
連得到外面:HTTP 200

我這台沒有 ~/.ssh 的金鑰、沒有裝過 AWS CLI,所以前兩條是空的。~/.npmrc 有。 那個檔案只有一行,//registry.npmjs.org/:_authToken=,我發套件用的。

~/.zsh_history 更難反駁。五萬九千字,我打過的每一行指令,包含那些手滑貼在命令列上的東西。

這份清單你要自己列,不要照抄我的。我原本還寫了 ~/.config/gh/hosts.yml,以為 GitHub token 在裡面,查下去才發現 gh 預設把 token 放系統鑰匙圈,那個檔案只有 82 個位元組,裡面是帳號名跟 git_protocol: https寫錯的那一版我已經跑過、看過輸出、覺得很有說服力。 差別只在後來有人叫我把 gh auth status 打出來。

最後那行更直接:它連得出去。

真的要偷的腳本不會像這樣印給你看。它會直接送走,而且那一行藏在你剛才捲過去的四十行 grep 中間。

問題不在這支腳本,在「跑」這個動作沒有範圍

我要講清楚一件事:上面那段程式碼本身不是漏洞。

[ -r ] 是標準寫法,curl 是你天天用的東西。它們變成問題,只因為執行它們的那個行程繼承了你的身分。你的檔案就是它的檔案,你的網路就是它的網路。

Day 1 那張信任邊界圖,界線畫在你的機器跟使用者的機器之間。今天那張圖裡面多了一格:

信任邊界第二版。一個大框是「你的機器」,也就是 Day 1 那條界線的界內。框裡由左到右三樣東西:「你寫的程式碼和金鑰」、一格紅色的「AI 生的腳本在這裡跑」、還有「家目錄,~/.ssh、~/.aws、~/.config」。左邊那兩個之間是一條虛線,標著「界內沒有再細分」。紅框射出兩條實線,一條標「它看得到什麼」指向家目錄,一條標「它連得去哪」穿出大框,指向框外的「外面,任何一台主機」

先看左邊那條虛線。紅框在界內,跟你自己寫的程式碼並排,中間沒有任何東西隔開。這就是問題本身:界內沒有再細分,所以它拿到的權限跟你本人一樣多,而裡面跑的東西不是你寫的。

然後看紅框射出去的兩條實線。那兩條是今天的主線:它看得到什麼、它連得去哪。等一下那串旗標會在這兩條之外再補三件事:它改得動什麼、能不能提權、能吃掉多少資源。

讓它去生設定草稿,然後把每一行都當成一個決定

隔離手段選容器。等一下會講沒有 Docker 的退路。

樣板這種東西給 AI 生就好,它比你快。

問之前先過一遍 Day 4 那份 AI-SHARING.md。這一步要送出去的只有「我要在容器裡跑一支來路不明的腳本」這件事,compose、.env、目錄樹都不用貼。樣板不需要看你的專案長什麼樣,它需要看的是你的要求裡寫了什麼。

我在 2026-08-06 用手邊一個小模型(Antigravity CLI 的 Gemini 3.5 Flash)問了兩次,故意用不一樣的問法。

第一次照大家平常的問法:「幫我在 Docker 裡跑我專案裡的 suspect.sh」。它回:

FROM alpine:latest
RUN apk update && apk add --no-cache bash
WORKDIR /app
COPY . .
RUN chmod +x suspect.sh
ENTRYPOINT ["/bin/bash", "./suspect.sh"]
docker run --rm suspect-app

COPY . . 把整個建置目錄複製進映像檔。.dockerignore 寫對了擋得掉 .env,而它沒有提醒你要寫。映像檔的層又是留著的,你之後推到哪裡它就跟到哪裡。你 Day 2 才把它從版本庫裡挖出來,現在它換一個地方待著。沒有任何網路限制。還有,沒有 USER,所以裡面是 root。

root 那條我故意放最後,因為等一下量出來的結果會跟你的直覺不一樣。

它最後還貼心補了一句:需要存取外部目錄的話,加 -v

第二次我把顧慮寫進問題:「來路不明的腳本,不要讓它碰到我本機的東西」。同一個模型回的是:

FROM alpine:latest
RUN apk add --no-cache bash
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY suspect.sh .
RUN chmod +x suspect.sh
USER appuser
CMD ["./suspect.sh"]
docker run --rm --network none suspect-runner

COPY . . 變成只複製那一支,多了非 root 使用者,docker run 主動加上 --network none

同一個模型,同一支腳本,差別只在我有沒有把要求寫進去。 這就是這天的分工:草稿它出,但草稿的內容等於你問題裡寫了什麼。你沒說的它不會替你想,而漏掉的那幾項不會報錯,只會安靜地生效。

所以第二次那份也不能照收。我拿它當底改了兩個地方:腳本不 COPY 進映像檔,改成執行的時候用 -v 掛進去(換目標目錄的時候腳本才不會跟著不見),還有把進入點固定成 sh,這樣掛進來的檔案不需要有執行權限。

FROM alpine:3.22
RUN apk add --no-cache curl
RUN adduser -D -u 10001 runner
USER runner
WORKDIR /work
ENTRYPOINT ["sh"]

然後把 docker run 改成預設什麼都不給:

docker run --rm \
  --network none \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=16m \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --pids-limit 128 --memory 256m \
  -v "$PWD/suspect.sh:/suspect.sh:ro" \
  -v "$PWD:/work:ro" \
  -w /work \
  suspect-runner /suspect.sh

ENTRYPOINT ["sh"] 那行是有作用的:最後那個 /suspect.sh當參數傳給 sh,不是直接執行。掛進來的檔案是 0644,直接執行會拿到 permission denied,而那個錯誤訊息不會告訴你原因出在權限位。

旗標 收掉了什麼
--network none 不連外,容器裡只剩 loopback
--read-only 根檔案系統唯讀,寫不進去
--tmpfs /tmp 上一行擋掉之後真的需要暫存檔,只開這一格
--cap-drop ALL Linux capability 一個不留
--security-opt no-new-privileges 跑起來之後不能再提權
--pids-limit --memory 行程暴增與吃光記憶體的影響變小。管不到 CPU,要限制得另外加 --cpus
-v "$PWD:/work:ro" 唯一「你明確放進去」的宿主機目錄,而且唯讀

每一項的官方說明我抓成快照放在 recipe 的 sources/,附對照表,你可以逐句核(Docker 官方文件,2026-08 查證)。這張表你現在不用記。 它的用途是等你哪天真的要打開其中一項的時候,回來看那一行收掉的是什麼。

最後那行的 suspect-runner 是映像檔的名字,docker build -t suspect-runner . 建出來的。

五步,然後才是指令

中間那段我改了很多次,講的都是驗證腳本怎麼被打回來。那部分有用,但它會蓋掉主線,所以先把主線收成五步:

  1. 預設全關,把上面那串旗標整組貼上
  2. 跑跑看,它會壞
  3. 看它壞在哪,那就是它真正需要的東西,一次現形一個
  4. 一次只放行一項,改那一個掛載,不要把整條規則拿掉
  5. 重新驗證沒放行的那幾條,容器突然多看得到的東西,只能是你剛剛放進去的那一項

後面的內容都在填第 5 步:怎麼知道「重新驗證」不是在自我安慰。

你自己跑一次

先讀再跑,這篇整天在講這件事。 suspect.sh 三十行,掃一眼就知道它做什麼。要注意的有兩件:它會把你的家目錄唯讀掛進容器一次(第 4 節,那正是它要示範的),跑完會留一個約 14 MB 的映像檔。

git clone https://github.com/cyh7789/ai-security
cd ai-security/recipes/06-run-it-somewhere-else
less suspect.sh verify.sh     # 這一行不要跳過
bash verify.sh

它會建映像檔、跑四節,最後印這一行:

════════ 10 綠 / 0 紅 / 0 跳過 ════════

看到紅的不是你做錯,是那一節在你的機器上結論跟我不一樣,往上看它印了什麼。沒裝 Docker 的話第 2 到 4 節會跳過,而跳過會單獨印一行,不會混進綠燈的數字裡。

要換成你自己的專案,run-isolated.sh 後面接目錄:

bash run-isolated.sh ~/你的專案

講清楚它做什麼:這樣是把那個目錄唯讀掛進容器,讓 recipe 內建的 suspect.sh 去看它,不是去執行你專案裡的腳本。要跑你自己那支,把最後那個 /suspect.sh 換成你掛進去的路徑。

同一支腳本,換個地方跑

驗證的方式是別動腳本,只換執行的地方,然後比它碰得到什麼。

=== 2. 同一支腳本,關進預設全關的容器 ===
  | 探針(不跑 suspect.sh):SETUP_OK ABSENT=3 UNREADABLE=0 READ=0
  [OK]   容器裡看得到的掛載就是 flags.sh 白名單那三個,沒有多
  | 讀不到 /Users/you/.npmrc
  | 讀不到 /Users/you/.zsh_history
  | 讀不到 /Users/you/.gitconfig
  | 連不出去
  [OK]   掛載到位、腳本在容器裡(這句由驗證腳本自己的探針證明,不是 suspect.sh 說的)
  [OK]   探針去找那 3 個檔案,3 個都不存在。家目錄真的沒進來
  [OK]   suspect.sh 的輸出跟探針一致:本機讀得到的那幾條,容器裡一條都讀不到
  | 對照組(同一份旗標,只濾掉 --network none):連得到外面:HTTP 200
  [OK]   同一個容器不加旗標連得到、加了連不到,所以擋住它的是 --network none

剛剛在本機可讀的那三條,現在讀不到了。網路也沒了。

那些「探針」「對照組」的行是後來補的,補的過程比結果值得講。

我第一版只有兩條檢查:讀不到、連不出去。看起來很乾淨,跑起來全綠。

問題是這一整節的檢查都是「某個東西找不到」的形狀,而找不到有很多種原因。腳本根本沒在容器裡跑起來,找不到。掛載沒生效,找不到。容器裡壓根沒有 curl,也找不到。這三種情況跟「隔離成功」印出來的東西一模一樣。

我後來實測了三種「隔離其實沒生效」的配置:把腳本路徑改成不存在的、把整個 -v 拿掉、映像檔裡不裝 curl 而且連 --network none 都拿掉。三種都跑出滿分綠燈,其中一次的綠燈句子還指名了一個當下根本不在命令列上的旗標。

所以現在每一條「找不到」都配一條「找得到」:

「找不到」那條 撐它的「找得到」
容器裡讀不到家目錄的檔案 本機讀得到,而且容器真的架起來了
--network none 連不出去 同一個容器不加那個旗標就連得到
唯讀擋掉寫入 /tmp 寫得進去,而且掛載真的在
沒有多出別的掛載 直接列出容器裡看得到的掛載點,跟白名單逐項比

右欄不是額外的謹慎,是左欄成立的前提。少了它,你量到的不是隔離,是「什麼事都沒發生」。

還有一層更容易漏掉的:那個「找得到」不能由被測的東西自己提供。 我一開始的判準是「suspect.sh 的輸出裡有沒有那行標題」,可是 suspect.sh 正是你還沒讀完的那支,它想印什麼都行,印完 exit 0 就騙過去了。所以現在容器架好了沒,是驗證腳本自己另外跑一發探針去問的,那一發根本不執行 suspect.sh

這是 Day 5 那條規矩的同一個形狀:一個在沒做事的時候也會顯示成功的檢查,不是驗證。

然後我加了一行 -v,剛剛那些綠燈全部沒了

-v "$HOME:/host:ro"。就這一行。

網路一樣關著。唯讀一樣開著。一樣不是 root 在跑。

=== 4. 只多加一行 -v,第 2 節的結論就沒了 ===
  | 探針:SETUP_OK ABSENT=0 UNREADABLE=0 READ=3
  [OK]   /host 掛進去了,而且探針讀得到 3/3 個剛剛讀不到的檔案
  | 可讀   /host/.npmrc
  | 可讀   /host/.zsh_history
  | 可讀   /host/.gitconfig
  [OK]   掛了家目錄之後,容器讀得到的跟本機一模一樣。容器還在,隔離沒了

那個 npm token 又回來了,~/.zsh_history 也是。

補一句限定,它會影響你自己跑出來的結果。.npmrc 這種檔案通常是 0600,只有主人讀得到。原生 Linux 的 bind mount 直接沿用宿主機的 uid 與權限位,所以你在 Linux 上用非 root 的容器使用者跑,很可能會看到「讀不到」。那不代表隔離生效了,代表權限本來就擋著。 分辨的方法就是上面那張表:先確認沒掛的時候讀不到、掛了之後讀得到,中間那個差才是掛載造成的。

標題講的是「控制」,這裡要把話說明白:今天量到的只有一半。

今天示範的是它看得到什麼連得去哪。第三件事是免費附贈的:直接在本機跑的時候,那個行程以你的身分執行,你改得動的檔案它就改得動。shell 設定檔、git hook、專案裡任何一支下次會被跑到的腳本。這個我沒有示範,因為在自己機器上示範的代價太高。

容器裡的兩道限制分別擋不同的東西,不要混:-v ... :ro 擋掛進去的專案,--read-only 擋容器自己的根檔案系統。第 3 節把兩個各拿掉一次,確認兩個都真的有在擋。但這兩道都只在容器裡有效,本機那一次執行從頭到尾沒有人擋。

那「AI 自己動手」呢?開場那個 CVE 已經是了:確認步驟被關掉之後,agent 跑 shell 指令不再問你。那不是它讀到什麼,是它做了什麼。Day 15 會走一次 agent 拿到工具之後的樣子(一條注入讓它去打內網 metadata),Day 19 處理「模型生的 shell 指令直接送進 exec()」。今天先把它關起來,那兩天再談它在裡面能做什麼。

「有沒有放進容器」不是判準,「掛了什麼、開了哪條線」才是。

容器不是一個盒子,它是一份清單。而在 AI 的流程裡,往清單上加項目的常常不是你:腳本跑不起來,你回去問它,它給你的下一句就是那行 -v

這不是假設。那個模型第一次回答的結尾就寫了「需要存取外部目錄的話加 -v」,講的正是這一行。它沒有騙你,它甚至是對的,你確實會需要。差別在於加下去的那一刻,沒有人告訴你剛剛取消了什麼。跟開場那個 CVE 同一個形狀:那次是把確認關掉,這次是把牆打開,只是這一次動手的是你,而建議是它給的。

真實一點的例子。我自己有個專案的 compose,六個服務,每一個都寫了同一行:

env_file:
  - .env.docker

只跑排程的那個 worker 因此拿到了金流的金鑰。它從來沒用過。這行不是誰疏忽寫錯的,是第一個服務要,複製到第二個,然後六個都有了。逐項放行的相反不是「全開」,是「複製貼上」。

那它真的需要的東西呢

全關之後腳本會開始壞掉,這是預期內的。壞掉的地方就是它真正需要的東西,一次一個現形,比你事先猜準得多。

最常第一個壞的是寫入。這類腳本通常要把結果落成一個檔案,而唯讀之下它連 touch 都不行:

| TMP_OK touch: /work/PWNED: Read-only file system NOCAP NNP(離開碼 0)

放行的寫法是只多開一個掛載,不是把 --read-only 拿掉:

-v "$PWD/report:/work/report"

同一個目錄樹,只有 report/ 這一層可寫。根檔案系統還是唯讀的,腳本想改自己旁邊的 .git 一樣改不動。

順帶一提,這一節的對照組問的不是「/tmp 寫不寫得進去」,是「/tmp 以外的地方寫不寫得進去」。前一種在 --read-only--tmpfs 一起消失的時候照樣全綠,因為那時候整個根檔案系統都能寫。

網路那條要小心得多,因為它沒有「只開一條縫」這種東西。

--network bridge 不是「允許連 npm registry」,是恢復一般對外網路。Docker 官方保證的只有 --network none 會讓容器只剩 loopback,它沒有提供網域允許清單這種功能。所以在一個還掛著你專案的容器裡把 none 換成 bridge,你同時打開了把那些內容送出去的路。唯讀擋不了這件事,讀出來送走不需要寫入。

要下載東西就拆成兩段:一段有網路但不掛任何專案目錄與憑證,只負責把套件抓下來;抓完回到沒有網路的那一段去跑。真的需要邊跑邊連,就架一個限定目的地的代理,讓容器只走得到那個代理。

放行完要重跑一次驗證,看的是沒放行的那幾條有沒有跟著鬆掉。判準寫死:容器突然多看得到的東西,只能是你剛剛明確放進去的那一項,多一個都不對。

最後記一個我自己出的包,它跟上面那張表是同一件事。

我原本在這一節寫了另一個發現:「把主機路徑掛到容器裡同一個絕對路徑,整個目錄會安靜消失」。我打了指令、看到 No such file or directory、寫進稿子。這句是錯的。

被審查打回來之後我重驗,才發現掛載參數在送到 docker 之前就被我這台機器上的工具改寫過了,改掉的正好是目的地那一段。我以為我量到了 Docker 的行為,量到的其實是我自己的環境。

看一眼就會穿幫,而我當初沒看:

CID=$(docker create --rm -v "$HOME:/host:ro" alpine true)
docker inspect "$CID" --format '{{json .HostConfig.Binds}}'

它印出來的字串必須跟你打進去的一模一樣。不一樣的話,你後面量到的每一個數字都是別的東西的。

你的量測工具也在待驗證的範圍裡面。 這一整天講的就是這件事,只是我先示範了一次反例。

沒有 Docker 的話

退路是一個獨立的低權限使用者。Linux 上是 sudo useradd -m runner,把腳本放進它讀得到的地方,再 sudo -u runner bash fix.sh。你的家目錄它讀不到,這一半有效。

但如果你在 Mac 上,這行照打會失敗。 macOS 沒有 useradd 也沒有 adduser(我在自己機器上確認過,2026-08-06),要建使用者得走 sysadminctl 或系統設定,為了跑一支腳本這樣做太重了。Mac 上實際的答案是裝一個容器執行環境,Docker Desktop、OrbStack、Colima 都可以,個人用都不用錢。

而且就算你建了那個使用者,要誠實講清楚它比容器差在哪:網路它照連,系統上所有全域可讀的東西它照看,而且你得記得別把要跑的目錄權限開太大。

還有一個零安裝的做法,適用範圍窄但今天就能用:把腳本先讀一遍,把裡面所有 curlwgetssh| sh 的行找出來,逐行問「這一行要連去哪裡」。這不是隔離,是審查。 它擋得住你看得懂的那些,擋不住混淆過的。

它擋得住的,跟它擋不住的

擋得住的是你本機這一次執行。

擋不住的是別的地方。你的 CI 沒有人幫它加 --network none,正式環境的容器本來就要連資料庫、要讀掛進去的憑證。同一個惡意套件在那兩個地方照樣跑,而且跑在權限更大的位置。

所以容器不能拿來取代「這個東西該不該裝進來」的判斷。這兩件事的分界很清楚:今天管的是執行的時候把它關在哪裡,明天管的是裝進來之前信不信它。 把兩者混為一談的後果,就是以為有沙箱就不用看套件來源。

收工前

你手上多了一份此專案的隔離執行設定,而且它是驗證過的,不是抄來的。

驗證過的意思很具體:你有一份「本機碰得到什麼」的清單,和一份「容器裡碰得到什麼」的清單,兩份不一樣,而且你知道差在哪一行旗標。

--network none--read-only--cap-drop ALL-v ... :ro。四樣預設關掉的東西,之後每一次你要打開其中一個,都會是一個你看得見的動作。

還有一份更小但更有用的東西:那張白名單。容器裡應該只看得到哪幾個掛載點,寫成一行。之後你每加一個 -v,那一行就會告訴你多了什麼。

recipe 裡另外附了兩個東西,都是被審查打回來才補的。mutations.sh 會故意把隔離弄壞十三種,看驗證腳本會不會抓到,有一種沒抓到就是假綠燈。PROMPTS.md 是上面那組「同一個模型問兩次」的原始提示、回答節錄和工具版本,我講的差別你可以自己重跑一次對。

「預設不給、逐項放行」這個形狀,Day 15 給 agent 開權限的時候會一模一樣地再出現一次。到時候清單上的項目換成工具,判斷方式不變。

攻擊集今天沒有長,還是 2 條。今天長的是另一邊:你有了一個地方可以跑那些你還沒讀完的東西。

明天

今天處理的是「跑」,前提是那個東西已經在你機器上了。

明天往前推一步:它叫你 npm install 的那個套件,是誰發的?上一次更新是什麼時候?

不過在那之前有一個更基本的問題,基本到你可能沒想過要問:那個套件存在嗎


上一篇:Day 5|AI 找得到自己寫的洞,是我的驗證方法騙了我

今天這四節驗證:recipe 06|範例專案:github.com/cyh7789/ai-security


上一篇
Day 5|AI 找得到自己寫的洞,是我的驗證方法騙了我
系列文
你該防的不是駭客,是你自己的 AI:在本機驗證你的防線6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言