11 min read

HITCON ZeroDay 通報整理器 - 自動化整理你的漏洞通報管理

HITCON ZeroDay 通報整理器 - 自動化整理你的漏洞通報管理

身為一個經常在 HITCON ZeroDay 上通報漏洞的白帽駭客,我發現自己需要一個工具來整理和管理這些通報記錄,手動一筆一筆複製實在太耗時,而且當通報數量越來越多時,統計和分析就變得非常困難,於是我開發了這個 JavaScript 工具,可以直接在瀏覽器中執行,自動抓取所有通報記錄並匯出成 CSV 和 JSON 格式。

🎯 為什麼需要這個工具?

問題背景

HITCON ZeroDay 是台灣重要的漏洞通報平台,當你累積了數十筆甚至上百筆通報後,想要:

  • 📊 統計分析 - 查看自己通報的漏洞類型分布
  • 📝 製作報告 - 整理通報記錄做成個人履歷
  • 💾 資料備份 - 定期備份自己的通報內容
  • 📈 趨勢追蹤 - 觀察自己的通報趨勢

技術挑戰

最初我嘗試用 Python 的 requestsBeautifulSoup 來抓取,但遇到了 Cloudflare 的 JavaScript Challenge:

# 這段 Python 程式碼會失敗
response = requests.get('https://zeroday.hitcon.org/user/kevin2758/vulnerability')
# 結果:403 Forbidden

即使設定了正確的 cookies 和 headers,Cloudflare 還是能檢測出這是自動化請求。經過研究後發現,最簡單且有效的方法就是直接在瀏覽器中執行 JavaScript

✨ 解決方案:瀏覽器 Console 腳本

這個工具的核心概念很簡單:既然 Cloudflare 會擋自動化工具,那我們就讓程式在「真實的瀏覽器」中執行。

主要特色

1. 兩種模式

快速模式

  • 只抓取列表頁資訊
  • 適合快速統計和查看

完整模式

  • 額外抓取每筆通報的詳細內容
  • 包含漏洞類型、詳細描述、修補建議
  • 適合製作完整報告或備份

2. 自動化處理

  • 自動翻頁抓取所有通報
  • 自動下載 CSV 和 JSON 檔案
  • 提供即時進度顯示

🚀 使用方法

步驟 1:登入 HITCON ZeroDay

前往 https://zeroday.hitcon.org 並登入你的帳號。

步驟 2:開啟開發者工具

在通報列表頁面按下 F12開啟開發者工具,然後切換到 Console 標籤。

步驟 3:執行腳本

複製以下完整程式碼,貼到 Console 中,然後按 Enter

步驟 4:選擇模式

程式會詢問你要使用哪個模式:

選擇模式:
1. 快速模式 - 只抓列表資訊
2. 完整模式 - 包含每篇詳細內容

選擇模式 (1=快速, 2=完整): 
  • 輸入 1 → 快速模式
  • 輸入 2 → 完整模式(推薦)

步驟 5:等待完成

程式會自動:

  1. 抓取所有頁面的通報列表
  2. (完整模式)逐筆抓取詳細內容
  3. 顯示統計資料
  4. 自動下載 CSV 和 JSON 檔案

📊 輸出格式

CSV 欄位說明

快速模式:

序號, ZD_ID, 標題, 風險等級, 狀態, 受影響組織, 通報日期, 詳細頁URL

完整模式(額外 4 個欄位):

..., 漏洞類型, 相關網址, 詳細描述, 修補建議

實際範例

序號,ZD_ID,標題,風險等級,狀態,受影響組織,通報日期,詳細頁URL,漏洞類型,相關網址,詳細描述,修補建議
1,ZD-2025-01374,XXXX科技 客戶檢驗報告外洩,高,審核完成,XXXX科技股份有限公司,2025/11/03,https://zeroday.hitcon.org/vulnerability/ZD-2025-01374,資訊洩漏 (Information Leakage),https://xxx/report/,"檢驗報告儲存目錄開啟 Directory Listing,任何人無需認證即可下載所有客戶的檢驗報告","1. 立即關閉目錄訪問並移除所有公開報告 2. 實施身份驗證"

💻 完整程式碼

以下是完整的工具程式碼,可以直接複製使用:

/**
 * HITCON ZeroDay 通報抓取工具 - 完整版
 * 包含詳細頁面內容抓取
 * 
 * 使用方法:
 * 1. 登入 HITCON ZeroDay
 * 2. 前往 https://zeroday.hitcon.org/user/kevin2758/vulnerability
 * 3. 按 F12 打開開發者工具
 * 4. 切到 Console 標籤
 * 5. 複製貼上整個腳本並按 Enter
 * 6. 選擇模式(快速/完整)
 * 7. 等待自動抓取完成
 * 8. 會自動下載 CSV 和 JSON 檔案
 */

(async function() {
    console.log('='.repeat(60));
    console.log('HITCON ZeroDay 通報抓取工具 - 完整版');
    console.log('='.repeat(60));
    console.log('');
    
    // 設定
    const BASE_URL = 'https://zeroday.hitcon.org/user/kevin2758/vulnerability';
    const DELAY_MS = 10000; // 列表頁之間延遲
    const DETAIL_DELAY_MS = 5000; // 詳細頁之間延遲
    
    // 儲存所有通報
    let allReports = [];
    
    /**
     * 解析風險等級
     */
    function parseRiskLevel(classList) {
        if (classList.contains('col-risk-critical')) return '嚴重';
        if (classList.contains('col-risk-high')) return '高';
        if (classList.contains('col-risk-medium')) return '中';
        if (classList.contains('col-risk-low')) return '低';
        return '未知';
    }
    
    /**
     * 取得總頁數
     */
    function getTotalPages() {
        const lastPageElem = document.querySelector('.last-page');
        return lastPageElem ? parseInt(lastPageElem.textContent.trim()) : 1;
    }
    
    /**
     * 抓取指定頁面的 HTML
     */
    async function fetchPage(pageNum) {
        const url = pageNum === 1 ? BASE_URL : `${BASE_URL}/page/${pageNum}`;
        
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`HTTP ${response.status}`);
            }
            const html = await response.text();
            
            // 創建一個臨時 DOM 來解析
            const parser = new DOMParser();
            const doc = parser.parseFromString(html, 'text/html');
            
            return doc;
        } catch (error) {
            console.error(`抓取第 ${pageNum} 頁失敗:`, error);
            throw error;
        }
    }
    
    /**
     * 抓取詳細頁面
     */
    async function fetchDetailPage(zdId) {
        const url = `https://zeroday.hitcon.org/vulnerability/${zdId}`;
        
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`HTTP ${response.status}`);
            }
            const html = await response.text();
            
            const parser = new DOMParser();
            const doc = parser.parseFromString(html, 'text/html');
            
            return doc;
        } catch (error) {
            console.error(`  ⚠️  抓取 ${zdId} 詳細頁失敗:`, error);
            return null;
        }
    }
    
    /**
     * 從詳細頁面提取資訊
     */
    function extractDetailInfo(doc) {
        if (!doc) return {};
        
        const detail = {};
        
        try {
            // 漏洞類型
            const infoSection = doc.querySelector('.vul-detail-section .section-content.info');
            if (infoSection) {
                const items = infoSection.querySelectorAll('li');
                items.forEach(li => {
                    const text = li.textContent.trim();
                    if (text.startsWith('類型:')) {
                        detail.漏洞類型 = text.replace('類型:', '').trim();
                    }
                });
            }
            
            // 相關網址
            const urlSection = doc.querySelector('.vu-d-url .urls');
            if (urlSection) {
                detail.相關網址 = urlSection.textContent.trim();
            }
            
            // 詳細描述
            const descSection = doc.querySelector('#detail');
            if (descSection) {
                // 移除 HTML 標籤,只保留文字
                const tempDiv = document.createElement('div');
                tempDiv.innerHTML = descSection.innerHTML;
                detail.詳細描述 = tempDiv.textContent.trim().replace(/\s+/g, ' ');
            }
            
            // 修補建議
            const remedySection = Array.from(doc.querySelectorAll('.vul-detail-section')).find(section => {
                const title = section.querySelector('.sec-title');
                return title && title.textContent.includes('修補建議');
            });
            if (remedySection) {
                const content = remedySection.querySelector('.section-content');
                if (content) {
                    detail.修補建議 = content.textContent.trim().replace(/\s+/g, ' ');
                }
            }
            
        } catch (error) {
            console.error('  解析詳細頁時出錯:', error);
        }
        
        return detail;
    }
    
    /**
     * 從文檔中抓取通報
     */
    function scrapeFromDocument(doc) {
        const reports = [];
        const items = doc.querySelectorAll('li.strip.allow-edit');
        
        console.log(`  找到 ${items.length} 筆通報`);
        
        items.forEach((item, index) => {
            try {
                // ZD ID
                const codeElem = item.querySelector('li.code');
                const zdId = codeElem ? codeElem.textContent.replace('HZD Code:', '').trim() : '';
                
                // 標題和 URL
                const titleElem = item.querySelector('h4.title a');
                const title = titleElem ? titleElem.textContent.trim() : '';
                const detailUrl = titleElem ? titleElem.getAttribute('href') : '';
                
                // 風險等級
                const riskElem = item.querySelector('li.risk');
                const risk = riskElem ? parseRiskLevel(riskElem.classList) : '未知';
                
                // 狀態
                const statusElem = item.querySelector('li.status');
                const status = statusElem ? statusElem.textContent.replace('Status:', '').trim() : '';
                
                // 組織名稱
                const vendorElem = item.querySelector('li.vendor .v-name-short');
                const vendor = vendorElem ? vendorElem.textContent.trim() : '';
                
                // 日期
                const dateElem = item.querySelector('li.date');
                const date = dateElem ? dateElem.textContent.replace('Date:', '').trim() : '';
                
                const report = {
                    序號: allReports.length + reports.length + 1,
                    ZD_ID: zdId,
                    標題: title,
                    風險等級: risk,
                    狀態: status,
                    受影響組織: vendor,
                    通報日期: date,
                    詳細頁URL: detailUrl ? `https://zeroday.hitcon.org${detailUrl}` : '',
                    // 以下欄位在完整模式下會填入
                    漏洞類型: '',
                    相關網址: '',
                    詳細描述: '',
                    修補建議: ''
                };
                
                reports.push(report);
            } catch (error) {
                console.error(`  解析第 ${index + 1} 筆通報時出錯:`, error);
            }
        });
        
        return reports;
    }
    
    /**
     * 轉換成 CSV 格式
     */
    function convertToCSV(reports) {
        if (reports.length === 0) return '';
        
        // CSV 標題
        const headers = Object.keys(reports[0]);
        const csvHeaders = headers.join(',');
        
        // CSV 內容
        const csvRows = reports.map(report => {
            return headers.map(header => {
                const value = report[header] || '';
                // 處理包含逗號、引號、換行的值
                const escaped = value.toString().replace(/"/g, '""');
                return `"${escaped}"`;
            }).join(',');
        });
        
        return csvHeaders + '\n' + csvRows.join('\n');
    }
    
    /**
     * 下載檔案
     */
    function downloadFile(content, filename, mimeType) {
        const blob = new Blob([content], { type: mimeType });
        const url = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = url;
        link.download = filename;
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        URL.revokeObjectURL(url);
    }
    
    /**
     * 下載 CSV
     */
    function downloadCSV(reports) {
        const csv = convertToCSV(reports);
        const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, '').replace('T', '_');
        const filename = `hitcon_reports_${timestamp}.csv`;
        downloadFile('\ufeff' + csv, filename, 'text/csv;charset=utf-8;');
        console.log(`✓ 已下載: ${filename}`);
    }
    
    /**
     * 下載 JSON
     */
    function downloadJSON(reports) {
        const json = JSON.stringify(reports, null, 2);
        const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, '').replace('T', '_');
        const filename = `hitcon_reports_${timestamp}.json`;
        downloadFile(json, filename, 'application/json');
        console.log(`✓ 已下載: ${filename}`);
    }
    
    /**
     * 主要執行流程
     */
    async function main() {
        try {
            // 取得總頁數(從當前頁面)
            const totalPages = getTotalPages();
            console.log(`總共有 ${totalPages} 頁通報\n`);
            
            // 選擇模式
            const modes = {
                '1': { name: '快速模式', fetchDetails: false },
                '2': { name: '完整模式(包含詳細內容)', fetchDetails: true }
            };
            
            console.log('%c選擇模式:', 'font-weight: bold; font-size: 14px;');
            console.log('1. 快速模式 - 只抓列表資訊');
            console.log('2. 完整模式 - 包含每篇詳細內容(約 ' + Math.ceil(totalPages * 10 * DETAIL_DELAY_MS / 1000 / 60) + ' 分鐘)');
            
            const modeChoice = prompt('選擇模式 (1=快速, 2=完整):', '1');
            
            if (!modeChoice || !modes[modeChoice]) {
                console.log('已取消或選擇無效');
                return;
            }
            
            const mode = modes[modeChoice];
            console.log(`\n使用 ${mode.name}\n`);
            
            // 確認執行
            if (totalPages > 5 || mode.fetchDetails) {
                const estimatedTime = mode.fetchDetails 
                    ? Math.ceil(totalPages * 10 * DETAIL_DELAY_MS / 1000)
                    : Math.ceil(totalPages * DELAY_MS / 1000);
                
                const proceed = confirm(
                    `${mode.name}\n` +
                    `總共 ${totalPages} 頁\n` +
                    `預計需要 ${estimatedTime} 秒\n\n` +
                    `要繼續嗎?`
                );
                
                if (!proceed) {
                    console.log('已取消');
                    return;
                }
            }
            
            // 先抓取當前頁(第 1 頁)
            console.log(`[1/${totalPages}] 正在抓取第 1 頁(當前頁)...`);
            const firstPageReports = scrapeFromDocument(document);
            allReports.push(...firstPageReports);
            console.log(`✓ 第 1 頁完成,累計 ${allReports.length} 筆通報`);
            
            // 逐頁抓取其他頁面
            for (let page = 2; page <= totalPages; page++) {
                console.log(`\n[${page}/${totalPages}] 正在抓取第 ${page} 頁...`);
                
                const doc = await fetchPage(page);
                const reports = scrapeFromDocument(doc);
                allReports.push(...reports);
                
                console.log(`✓ 第 ${page} 頁完成,累計 ${allReports.length} 筆通報`);
                
                if (page < totalPages) {
                    await new Promise(resolve => setTimeout(resolve, DELAY_MS));
                }
            }
            
            console.log('\n' + '='.repeat(60));
            console.log(`列表頁抓取完成!總共 ${allReports.length} 筆通報`);
            console.log('='.repeat(60));
            
            // 如果是完整模式,抓取每篇詳細內容
            if (mode.fetchDetails) {
                console.log('\n開始抓取詳細內容...\n');
                
                for (let i = 0; i < allReports.length; i++) {
                    const report = allReports[i];
                    const progress = `[${i + 1}/${allReports.length}]`;
                    
                    console.log(`${progress} ${report.ZD_ID} - ${report.標題.substring(0, 30)}...`);
                    
                    const detailDoc = await fetchDetailPage(report.ZD_ID);
                    const detailInfo = extractDetailInfo(detailDoc);
                    
                    // 合併詳細資訊
                    Object.assign(report, detailInfo);
                    
                    if ((i + 1) % 10 === 0) {
                        console.log(`  已完成 ${i + 1}/${allReports.length} 筆`);
                    }
                    
                    // 延遲避免請求太快
                    if (i < allReports.length - 1) {
                        await new Promise(resolve => setTimeout(resolve, DETAIL_DELAY_MS));
                    }
                }
                
                console.log('\n' + '='.repeat(60));
                console.log('詳細內容抓取完成!');
                console.log('='.repeat(60));
            }
            
            // 顯示統計
            console.log('');
            const stats = {
                總數: allReports.length,
                嚴重: allReports.filter(r => r.風險等級 === '嚴重').length,
                高: allReports.filter(r => r.風險等級 === '高').length,
                中: allReports.filter(r => r.風險等級 === '中').length,
                低: allReports.filter(r => r.風險等級 === '低').length
            };
            console.table(stats);
            
            // 下載檔案
            console.log('\n正在下載檔案...');
            downloadCSV(allReports);
            downloadJSON(allReports);
            
            console.log('\n🎉 完成!已下載 CSV 和 JSON 檔案');
            console.log('\n你也可以在 Console 中輸入以下指令查看資料:');
            console.log('- window.hitconReports    // 查看所有通報');
            console.log('- window.hitconReports[0] // 查看第一筆通報');
            console.log('- window.hitconReports.filter(r => r.風險等級 === "嚴重") // 篩選嚴重風險');
            
            if (mode.fetchDetails) {
                console.log('- window.hitconReports[0].詳細描述 // 查看詳細描述');
            }
            
            // 儲存到 window 物件方便查看
            window.hitconReports = allReports;
            
        } catch (error) {
            console.error('發生錯誤:', error);
            console.log('\n如果遇到問題,可以試試:');
            console.log('1. 重新整理頁面後再執行');
            console.log('2. 確認已登入 HITCON ZeroDay');
            console.log('3. 選擇快速模式(如果完整模式失敗)');
        }
    }
    
    // 開始執行
    await main();
    
})();

⚠️ 注意事項

使用限制

  1. 網路穩定性:完整模式需要較長時間,建議在網路穩定時執行
  2. 請求頻率:程式已經加入延遲,避免對伺服器造成負擔
  3. 使用者名稱:記得將 BASE_URL 中的 kevin2758 改成你的使用者名稱

客製化建議

調整延遲時間

const DELAY_MS = 10000;        // 列表頁延遲
const DETAIL_DELAY_MS = 5000;  // 詳細頁延遲

修改使用者名稱

const BASE_URL = 'https://zeroday.hitcon.org/user/YOUR_USERNAME/vulnerability';

只抓取前幾頁(測試用)

const totalPages = Math.min(getTotalPages(), 3); // 只抓前 3 頁

🎯 總結

這個工具解決了我長期以來的痛點,讓通報管理變得更加輕鬆,透過瀏覽器 Console 執行的方式,如果你也在使用 HITCON ZeroDay,不妨試試這個工具!