例項講解黑客如何執行SQL隱碼攻擊

edithfang發表於2015-01-15
一位客戶讓我們針對只有他們企業員工和顧客能使用的企業內網進行滲透測試。這是安全評估的一個部分,所以儘管我們之前沒有使用過SQL隱碼攻擊來滲透網路,但對其概念也相當熟悉了。最後我們在這項任務中大獲成功,現在來回顧一下這個過程的每一步,將它記錄為一個案例。

“SQL隱碼攻擊”是一種利用未過濾/未稽核使用者輸入的攻擊方法(“快取溢位”和這個不同),意思就是讓應用執行本不應該執行的SQL程式碼。如果應用毫無防備地建立了SQL字串並且執行了它們,就會造成一些出人意料的結果。

我們記錄下了在多次錯誤的轉折後經歷的曲折過程,而一個更有經驗的人會有這不同的 — 甚至更好的 — 方法。但事實上我們成功以後才明白,我們並沒有完全被誤導。

其他的SQL文章包含了更多的細節,但是這篇文章不僅展示了漏洞利用的過程,還講述了發現漏洞的原理。

目標內網

展現在我們眼前的是一個完整定製網站,我們之前沒見過這個網站,也無權檢視它的原始碼:這是一次“黑盒”攻擊。‘刺探’結果顯示這臺伺服器執行在微軟的IIS6上,並且是ASP.NET架構。這就暗示我們資料庫是微軟的SQL server:我們相信我們的技巧可以應用在任何web應用上,無論它使用的是哪種SQL 伺服器。

登陸頁有傳統的使用者-密碼錶單,但多了一個 “把我的密碼郵給我”的連結;後來,這個地方被證實是整個系統陷落的關鍵。

當鍵入郵件地址時,系統假定郵件存在,就會在使用者資料庫裡查詢郵件地址,然後郵寄一些內容給這個地址。但我的郵件地址無法找到,所以它什麼也不會發給我。

對於任何SQL化的表單而言,第一步測試,是輸入一個帶有單引號的資料:目的是看看他們是否對構造SQL的字串進行了過濾。當把單引號作為郵件地址提交以後,我們得到了500錯誤(伺服器錯誤),這意味著“有害”輸入實際上是被直接用於SQL語句了。就是這了!

我猜測SQL程式碼可能是這樣:

SELECT fieldlist
  FROM table
 WHERE field = '$EMAIL';
$EMAIL 是使用者從表單提交的地址,並且這段查詢在字串末端$EMAIL上提供了引號。我們不知道欄位或表的確切名字,但是我們瞭解他們的本質,這有助於我們做正確的猜測。

當我們鍵入steve@unixwiz.net‘ -注意這個末端的引號 – 下面是這個SQL欄位的構成:

SELECT fieldlist
  FROM table
 WHERE field = 'steve@unixwiz.net'';


當這段SQL開始執行,SQL解析器就會發現多餘的引號然後中斷執行,並給出語法錯誤的提示。這個錯誤如何清楚的表述給使用者,基於應用內部的錯誤恢復規程,但一般來說都不會提示“郵件地址不存在”。這個錯誤響應成了死亡之門,它告訴別人使用者輸入沒有被正確的處理,這就為應用破解留下了可乘之機。

這個資料呈現在WHERE的從句中,讓我們以符合SQL規範的方式改變輸入試試,看看會發生什麼。鍵入anything’ OR ‘x’=‘x, 結果如下:

SELECT fieldlist
  FROM table
 WHERE field = 'anything' OR 'x'='x';
因為應用不會思考輸入 – 僅僅構造字串 – 我們使用單引號把WHERE從句的單一組成變成了雙組成,’x’=‘x’從句是恆成立的,無論第一個從句是什麼。(有一種更好的方式來確保“始終為真”,我們隨後會接觸到)。

但與每次只返回單一資料的“真實”查詢不同,上面這個構造必須返回這個成員資料庫的所有資料。要想知道在這種情況下應用會做什麼,唯一的方法就是嘗試,嘗試,再嘗試。我們得到了這個:
你的登入資訊已經被郵寄到了 random.person@example.com.

我們猜測這個地址是查詢到的第一條記錄。這個傢伙真的會在這個郵箱裡收到他忘記的密碼,想必他會很吃驚也會引起他的警覺。

我們現在知道可以根據自己的需要來篡改查詢語句了,儘管對於那些看不到的部分還不夠了解,但是我們注意到了在多次嘗試後得到了三條不同的響應:

  • “你的登入資訊已經被郵寄到了郵箱”
  • “我們不能識別你的郵件地址”
  • 伺服器錯誤


前兩個響應是有效的SQL,最後一個響應是無效的SQL:當猜測查詢語句結構的時候,這種區別非常有用。

模式欄位對映

第一步是猜測欄位名:我們合理的推測了查詢包含“email address”和“password”,可能也會有“US Mail address”或者“userid”或“phone number”這樣的欄位。我們特別想執行 SHOW TABLE語句, 但我們並不知道表名,現在沒有比較明顯的辦法可以拿到表名。

我們進行了下一步。在每次測試中,我們會用我們已知的部分加上一些特殊的構造語句。我們已經知道這個SQL的執行結果是email地址的比對,因此我們來猜測email的欄位名:

SELECT fieldlist
  FROM table
 WHERE field = 'x' AND email IS NULL; --';


目的是假定的查詢語句的欄位名(email),來試試SQL是不是有效。我不關心匹配的郵件地址是啥(我們用了個偽名’x’),’——’這個符號表示SQL註釋的起始。對於去除應用末尾提供的引號,這是一個很有效的方式,我們不用在乎我們遮蔽掉的是啥。

如果我們得到了伺服器錯誤,意味著SQL有不恰當的地方,並且語法錯誤會被丟擲:更有可能是欄位名有錯。如果我們得到了任何有效的響應,我們就可以猜測這個欄位名是正確的。這就是我們得到“email unknown”或“password was sent”響應的過程。

我們也可以用AND連線詞代替OR:這是有意義的。在SQL的模式對映階段,我們不需要為猜一個特定的郵件地址而煩惱,我們也不想應用隨機的泛濫的給使用者發“這是你的密碼”的郵件 – 這不太好,有可能引起懷疑。而使用AND連線郵件地址,就會變的無效,我們就可以確保查詢語句總是返回0行,永遠不會生成密碼提醒郵件。

提交上面的片段的確給了我們“郵件地址未知”的響應,現在我們知道郵件地址的確是儲存在email欄位名裡。如果沒有生效,我們可以嘗試email_address或mail這樣的欄位名。這個過程需要相當多的猜測。

接下來,我們猜測其他顯而易見的名字:password,user ID, name等等。每次只猜一個欄位,只要響應不是“server failure”,那就意味著我們猜對了。

SELECT fieldlist
  FROM table
 WHERE email = 'x' AND userid IS NULL; --';


在這個過程中,我們找到了幾個正確的欄位名:

  • email
  • passwd
  • login_id
  • full_name


無疑還有更多(有一個線索是表單中的欄位名),一陣挖掘後沒有發現更多了。但是我們依然不知道這些欄位名的表名,它們在哪找到的?

尋找資料庫表名

應用的內建查詢指令已經建立了表名,但是我們不知道是啥:有幾個方法可以找到表名。其中一個是依靠subselect(字查詢)。

一個獨立的查詢

SELECT COUNT(*) FROM tabname


返回表裡記錄的數量,如果表名無效,查詢就會失敗。我們可以建立自己的字串來探測表名:

SELECT email, passwd, login_id, full_name
  FROM table
 WHERE email = 'x' AND 1=(SELECT COUNT(*) FROM tabname); --';


我們不關心到底有多少條記錄,只關心表名是不是正確。重複多次猜測以後,我們終於發現members是這個資料庫裡的有效表名。但它是用在這個查詢裡的麼?所以我們需要另一個測試,使用table.field:實際查詢的部分只工作在這個表中,而不是隻要表存在就執行。

SELECT email, passwd, login_id, full_name
  FROM members
 WHERE email = 'x' AND members.email IS NULL; --';


當返回“Email unknown”時,就意味著我們的SQL隱碼攻擊成功了,並且我們正確的猜測出了表名。這對後面的工作很重要,但是我們暫時先試試其他的方法。

找使用者賬號

我們對members表的結構有了一個區域性的概念,但是我們僅知道一個使用者名稱:任意使用者都可能得到“Here is your password”的郵件。回想起來,我們從未得到過資訊本身,只有它傳送的地址。我們得再弄幾個使用者名稱,這樣就能得到更多的資料。

首先,我們從公司網站開始找幾個人:“About us”或者“Contact”頁通常提供了公司成員列表。通常都包含郵件地址,即使它們沒有提供這個列表也沒關係,我們可以根據某些線索用我們的工具找到它們。

LIKE從句可以進行使用者查詢,允許我們在資料庫裡區域性匹配使用者名稱或郵件地址,每次提交如果顯示“We sent your password”的資訊並且郵件也真發了,就證明生效了。

警告:這麼做拿到了郵件地址,但也真的發了郵件給對方,這有可能引起懷疑,小心使用。

我們可以查詢email name或者full name(或者推測出來的其他資訊),每次放入%萬用字元進行如下查詢:

SELECT email, passwd, login_id, full_name
  FROM members
 WHERE email = 'x' OR full_name LIKE '%Bob%';


記住儘管可能不只有一個“Bob”,但我們只能看到一條資訊:建議精煉LIKE從句。

密碼暴力破解

可以肯定的是,我們能在登陸頁進行密碼的暴力破解,但是許多系統都針對此做了監測甚至防禦。可能有的手段有操作日誌,帳號鎖定,或者其他能阻礙我們行動的方式,但是因為存在未過濾的輸入,我們就能繞過更多的保護措施。

我們在構造的字串裡包含進郵箱名和密碼來進行密碼測試。在我們的例子中,我們用了受害者bob@example.com 並嘗試了多組密碼。

SELECT email, passwd, login_id, full_name
  FROM members
 WHERE email = '<a href="mailto:bob@example.com">bob@example.com</a>' AND passwd = 'hello123';


這是一條很好使的SQL語句,我們不會得到伺服器錯誤的提示,只要我們得到“your password has been mailed to you”的提示資訊,就證明我們已經得到密碼了。這時候受害人可能會警覺起來,但誰關心他呢,我們已經得到密碼了。

這個過程可以使用perl指令碼自動完成,然而,我們在寫指令碼的過程中,發現了另一種方法來破解系統。

資料庫不是隻讀的

迄今為止,我們沒做查詢資料庫之外的事,儘管SELECT是隻讀的,但不代表SQL只能這樣。SQL使用分號表示結束,如果輸入沒有正確過濾,就沒有什麼能阻止我們在字串後構造與查詢無關的指令。

The most drastic example is:

這劑猛藥是這樣的:

SELECT email, passwd, login_id, full_name
  FROM members
 WHERE email = 'x'; DROP TABLE members; --';  -- Boom!


第一部分我們準備了一個偽造的email地址——‘X’——我們不關心查詢結果返回什麼:我們想要得到的只是我們自己構造的SQL指令。這次攻擊刪除了整個members表,這就不太好玩了。

這表明我們不僅僅可以切分SQL指令,而且也可以修改資料庫。這是被允許的。

新增新使用者

我們已經瞭解了members表的區域性結構,新增一條新紀錄到表裡視乎是一個可行的方法:如果這成功了,我們就能簡單的用我們新插入的身份登陸到系統了。

不要太驚訝,這條SQL有點長,我們把它分行顯示以便於理解,但它依然是一條語句:

SELECT email, passwd, login_id, full_name
  FROM members
 WHERE email = 'x';
        INSERT INTO members ('email','passwd','login_id','full_name') 
        VALUES ('steve@unixwiz.net','hello','steve','Steve Friedl');--';


即使我們得到了正確的欄位名和表名,但在成功攻擊之前我們還有幾件事需要了解:

  • 在web表單裡,我們可能沒有足夠的空間鍵入這麼多文字(儘管可以用指令碼解決,但並不容易)。
  • web應用可能沒有members表的INSERT許可權。
  • 母庸置疑,members表裡肯定還有其他欄位,有一些可能需要初始值,否則會引起INSERT失敗。
  • 即使我們插入了一條新紀錄,應用也可能不正常執行,因為我們無法提供值的欄位名會自動插入NULL。
  • 一個正確的“member”可能額不僅僅只需要members表裡的一條紀錄,而還要結合其他表的資訊(如,訪問許可權),因此只新增一個表可能不夠。


在這個案例裡,我們遇到了問題#4或#5,我們無法確定到底是哪個—— 因為用構造好的使用者名稱登陸進去的時候,返回了伺服器錯誤的提示。儘管這就暗示了我們那些沒有構造的欄位是必須的,但我們沒有辦法正確處理。

一個可行的辦法是猜測其他欄位,但這是一個勞力費神的過程:儘管我們可以猜測其他“顯而易見”的欄位,但要想得到整個應用的組織結構圖太難了。
我們最後嘗試了其他方式。

把密碼郵給我

我們意識到雖然我們無法新增新紀錄到members資料庫裡,但我們可以修改已經存在的,這被證明是可行的。

從上一步得知 bob@example.com 賬戶在這個系統裡,我們用SQL隱碼攻擊把資料庫中的這條記錄改成我們自己的email地址:

SELECT email, passwd, login_id, full_name
  FROM members
 WHERE email = 'x';
      UPDATE members
      SET email = <a href="mailto:'steve@unixwiz.net">'steve@unixwiz.net</a>'
      WHERE email = <a href="mailto:'bob@example.com">'bob@example.com</a>';


執行之後,我們自然得到了“we didn’t know your email address”的提示,但這在預料之中,畢竟我們用了假的email地址。UPDATE操作不會通知應用,因此它悄然執行了。

之後,我們使用了“I lost my password”的功能,用我們剛剛更新的email地址,一分鐘後,我們收到了這封郵件:

From: <a href="mailto:system@example.com">system@example.com</a>
To: <a href="mailto:steve@unixwiz.net">steve@unixwiz.net</a>
Subject: Intranet login

This email is in response to your request for your Intranet log in information.
Your User ID is: bob
Your password is: hello


現在,我們要做的就是跟隨標準的登入流程進入系統,這是一個高等級職員,有高階許可權,比我們INSERT的使用者要好。

我們發現這個企業內部站點內容特別多,甚至包含了一個全使用者列表,我們可以合理的推出許多內網都有同樣的企業Windows網路帳號,它們可能在所有地方都使用同樣的密碼。我們很容易就能得到任意的內網密碼,並且我們找到了企業防火牆上的一個開放的PPTP協議的VPN埠,這讓登入測試變得更簡單。

我們又挑了幾個帳號測試都沒有成功,我們無法知道是否是“密碼錯誤”或者“企業內部帳號是否與Windows帳號名不同”。但是我們覺得自動化工具會讓這項工作更容易。

其他方法

在這次特定的滲透中,我們得到了足夠的許可權,我們不需要更多了,但是還有其他方法。我們來試試我們現在想到的但不夠普遍的方法。

我們意識到不是所有的方法都與資料庫無關,我們可以來試試。

呼叫xp_cmdshell

微軟的SQLServer支援儲存過程xp_cmdshell有許可權執行任意作業系統指令。如果這項功能允許web使用者使用,那webserver被滲透是無法避免的。

迄今為止,我們做的都被限制在了web應用和資料庫這個環境下,但是如果我們能執行任何作業系統指令,再厲害的伺服器也禁不住滲透。xp_cmdshell通常只有極少數的管理員賬戶才能使用,但它也可能授權給了更低階的使用者。

繪製資料庫結構

在這個登入後提供了豐富功能應用上,已經沒必要做更深的挖掘了,但在其他限制更多的環境下可能還不夠。

能夠系統的繪製出資料庫可見結構,包含表和它們的欄位結構,可能沒有直接幫助。但是這為網站滲透提供了一條林萌大道。

從網站的其他方面收集更多有關資料結構的資訊(例如,“留言板”頁?“幫助論壇”等?)。不過這對應用環境依賴強,而且還得靠你準確的猜測。

減輕危害

我們認為web應用開發者通常沒考慮到“有害輸入”,但安全人員應該考慮到(包括壞傢伙),因此這有3條方法可以使用。

輸入過濾

過濾輸入是非常重要的事,以確保輸入不包含危險程式碼,無論是SQL伺服器或HTM本身。首先想到的是剝掉“惡意字元”,像引號、分號或轉義符號,但這是一種不太好的方式。儘管找到一些危險字元很容易,但要把他們全找出來就難了。

web語言本身就充滿了特殊字元和奇怪的標記(包括那些表達同樣字元的替代字元),所以想要努力識別出所有的“惡意字元”不太可能成功。

換言之,與其“移除已知的惡意資料”,不如移除“良好資料之外的所有資料”:這種區別是很重要的。在我們的例子中,郵件地址僅能包含如下字元:

abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ
0123456789
@.-_+


允許不正確的字元輸入是沒有任何好處的,應該早點拒絕它們 – 可能會有一些錯誤資訊 – 不僅可以阻止SQL隱碼攻擊,也可以捕獲一些輸入錯誤而不是把它們存入資料庫。

某個特殊的email地址會讓驗證程式陷入麻煩,因為每個人對於“有效”的定義不同。由於email地址中出現了一個你沒有考慮到的字元而被拒絕,那真是糗大了。

真正的權威是RFC 2822(比RFC822內容還多),它對於”允許使用的內容“做了一個規範的定義。這種更學術的規範希望可以接受&和*(還有更多)作為有效的email地址,但其它人 – 包括作者 – 都樂於用一個合理的子集來包含更多的email地址。

那些採用更限制方法的人應當充分意識到沒有包含這些地址會帶來的後果,特別是限制有了更好的技術(預編譯/執行,儲存過程)來避免這些“奇怪”的字元帶來的安全問題。

意識到“過濾輸入”並不意味著僅僅是“移除引號”,因為即使一個“正規”的字元也會帶來麻煩。在下面這個例子中,一個整型ID值被拿來和使用者的輸入作比較(數字型PIN):

SELECT fieldlist
  FROM table
 WHERE id = 23 OR 1=1;  -- Boom! Always matches!


在實踐中,無論如何這個方法都有諸多限制,因為能夠徹底排除所有危險字元的欄位實在太少了。對於“日期”或者“email地址”或者“整型”,上面的辦法是有價值的,但對於真實的環境,我們不可避免地要使用其他方式來減輕危害。

輸入項編碼/轉義

現在可以過濾電話號碼和郵件地址了,但你不能通過同樣的方法處理“name”欄位,要不然可能會排除掉Bill O’Reilly這樣的名字:對於這個欄位,這裡的引號是合法的輸入。

有人就想到過濾到單引號的時候,再加上一個引號,這樣就沒問題了 – 但是這麼幹要出事啊!

預處理每個字串來替換單引號:

SELECT fieldlist
  FROM customers
 WHERE name = 'Bill O''Reilly';  -- works OK


這個方法很容易出問題,因為大部分資料庫都支援轉碼機制。像MySQL,允許輸入’來替代單引號,因此如果輸入 ‘; DROP TABLE users; — 時,通過兩次引號來“保護”資料庫,那我們將得到:

SELECT fieldlist
  FROM customers
 WHERE name = '\''; DROP TABLE users; --';  -- Boom!


‘’’ 是一個完整的SQL語句(只包含一個引號),通常,惡意SQL程式碼就會緊跟其後。不光是反斜線符號的情況:像Unicode編碼,其他的編碼或者解析規則都會無意中給程式設計師挖坑。完美的過濾的很困難的,這就是為什麼許多的資料庫藉口語言都提供函式給你使用。當同樣的內容給“string quoting”和“string parsing”處理過後,會好一些,也更安全一些。

比如MySQL的函式mysql_real_escape_string()和perl DBD 的 $dbh->quote($value)方法,這些方法都是必用的。

引數繫結 (預編譯語句)

儘管轉義是一個有用的機制,但我們任然處於“使用者輸入被當做SQL語句”這麼一個迴圈裡。更好的方法是:預編譯,本質上所有的資料庫程式設計介面都支援預編譯。技術上來說,SQL宣告語句是用問號給每個引數佔位建立的 – 然後在內部表中進行編譯。

預編譯查詢執行時是按照引數列表來的:

Perl中的例子

$sth = $dbh->prepare("SELECT email, userid FROM members WHERE email = ?;");

$sth->execute($email);


感謝Stefan Wagner幫我寫了個java實現:

不安全版

Statement s = connection.createStatement();
ResultSet rs = s.executeQuery("SELECT email FROM member WHERE name = "
                             + formField); // *boom*

安全版

PreparedStatement ps = connection.prepareStatement(
    "SELECT email FROM member WHERE name = ?");
ps.setString(1, formField);
ResultSet rs = ps.executeQuery();


$email 是從使用者表單獲得的,它作為#1(第一個問號標記的地方)位置的引數傳遞過來,在任何情況下這條SQL宣告都可以解析。引號,分號,反斜槓,SQL指令記號 – 任何字元都不會產生特殊效果,因為它們“只是資料”而已。這不會對其他東西造成破壞,因此這個應用很大程度上防範了SQL隱碼攻擊。

如果預編譯查詢語句多次(只編譯一次)執行,也會帶來效能上的提升,但是與大量安全方面的巨大提升相比,這顯得微不足道。這可能是我們保證web應用安全最重要的一步。

限制資料庫許可權和隔離使用者

在這個案例中,我們觀察到只有兩個互動動作不在登入使用者的上下文環境中:“登入”和“發密碼給我”。web應用應該對資料庫連線做許可權的限制:對於members表只能讀,並且無法操作其他表。

作用是即使一次“成功的”SQL隱碼攻擊也只能得到非常有限的成功。噢,我們將不能做有授權的UPDATE請求,我們要求助於其他方法。

一旦web應用確定登入表單傳遞來的認證是有效的,它就會切換會話到一個有更多許可權的使用者上。

對任何web應用而言,不使用sa許可權幾乎是根本不用說的事。

對資料庫的訪問採用儲存過程

如果資料庫支援儲存過程,請使用儲存過程來執行資料庫的訪問行為,這樣就不需要SQL了(假設儲存過程程式設計正確)。

把查詢,更新,刪除等動作規則封裝成一個單獨的過程,就可以針對基礎規則和所執行的商業規則來完成測試和歸檔(例如,如果客戶超過了信用卡限額,“新增新記錄”過程可能拒絕訂單)。

對於簡單的查詢這樣做可能僅僅能獲得很少的好處,不過一旦操作變複雜(或者被用在更多地方),給操作一個單獨的定義,功能將會變得更穩健也更容易維護。

注意:動態構建一個查詢的儲存過程是可以做到的:這麼做無法防止SQL隱碼攻擊 – 它只不過把預編譯/執行繫結到了一起,或者是把SQL語句和提供保護的變數繫結到了一起。

隔離web伺服器

實施了以上所有的防禦措施,仍然可能有某些地方有遺漏,導致了伺服器被滲透。設計者應該在假定壞蛋已經獲得了系統最高許可權下來設計網路設施,然後把它的攻擊對其他事情產生的影響限制在最小。

例如,把這臺機器放置在極度限制出入的DMZ網路“內部”,這麼做意味著即便取得了web伺服器的完全控制也不能自動的獲得對其他一切的完全訪問許可權。當然,這麼做不能阻止所有的入侵,不過它可以使入侵變的非常困難。

配置錯誤報告

一些框架的錯誤報告包含了開發的bug資訊,這不應該公開給使用者。想象一下:如果完整的查詢被現實出來了,並且指出了語法錯誤點,那要攻擊該有多容易。

對於開發者來說這些資訊是有用的,但是它應該禁止公開 – 如果可能 – 應該限制在內部使用者訪問。

注意:不是所有的資料庫都採用同樣的方式配置,並且不是所有的資料庫都支援同樣的SQL語法(“S”代表“結構化”,不是“標準的”)。例如,大多數版本的MySQL都不支援子查詢,而且通常也不允許單行多條語句(multiple statements):當你滲透網路時,實際上這些就是使問題複雜化的因素。

再強調一下,儘管我們選擇了“忘記密碼”連結來試試攻擊,但不是因為這個功能不安全。而是幾個易攻擊的點之一,不要把焦點聚集在“忘記密碼”上。

這個教學示例不準備全面覆蓋SQL隱碼攻擊的內容,甚至都不是一個教程:它僅僅是一篇我們花了幾小時做的滲透測試的記錄。我們看了其他的關於SQL隱碼攻擊文章的討論,但它們只給出了結果而沒有給出過程。

但是那些結果報告需要技術背景才能看懂,並且滲透細節也是有價值的。在沒有原始碼的情況下,滲透人員的黑盒測試能力也是有價值的。

感謝 David Litchfield 和 Randal Schwartz對本文的貢獻,還有Chris Mospaw的排版(&copy; 2005 by Chris Mospaw, used with permission).

其他資源

來自:程式師
相關閱讀
評論(1)

相關文章