:從抓包到Python模擬請求)
1. 逆向目標與思路拆解先說結論某電商平臺的sign簽名參數是整套反爬體系里最核心的一道鎖破解它不是靠某個“神器”而是靠一套穩(wěn)定的逆向流程。我去年花了整整兩個周末才徹底跑通這條路期間踩過的坑比寫過的代碼還多。這篇文章把完整流程拆開揉碎講清楚從抓包定位、斷點調試、JavaScript逆向到Python模擬請求每一步為什么這么做、失敗在哪里、怎么排查都會交代明白。我當時接到的需求很直接——需要抓取某電商平臺商品詳情頁的實時價格用于競品分析。一開始想著直接調接口拿數據結果發(fā)現請求參數里有個sign字段服務端會校驗它的合法性參數不對直接返回-15錯誤碼反爬校驗失敗。也就是說如果沒有合法的sign連商品價格都摸不到。先說清楚幾個概念方便沒有逆向經驗的讀者跟上節(jié)奏sign簽名客戶端在發(fā)起請求前把關鍵參數比如商品ID、時間戳、隨機數等按特定規(guī)則拼接經過加密算法MD5、SHA256、Hmac等生成一個固定長度的字符串放入請求參數中。服務端用同樣的邏輯計算一遍比對一致才認為是“合法客戶端”。該平臺的反爬機制除了sign還給請求頭埋了anti-content動態(tài)Token、cookie校驗pdd_user_id等但這篇的核心是攻破sign——它是整個鏈路里最難復制的一環(huán)。流行的方案無非兩種一是用瀏覽器自動化工具改URL參數后自動執(zhí)行頁面里的加密函數二是直接把頁面的核心JavaScript代碼摳出來在Node或Python環(huán)境里“還原簽名算法”。前者對反爬嚴的平臺很容易被檢測WebDriver特征明顯后者更可控也是業(yè)內處理此類問題的通用路徑。我在做方案選擇時直接排除了自動化方案原因有三個請求頻率一高賬號容易被風控鎖定WebDriver的特征比如navigator.webdriver為true對這個平臺的檢測庫來說太明顯很容易被識別自動化方案啟動慢、占資源大規(guī)模抓取時不現實。所以核心思路確定為抓包定位接口 - 鎖定生成sign的JavaScript文件 - 斷點調試還原算法 - Python重構簽名邏輯 - 批量請求校驗可用性。這是最可靠的鏈路下面按步驟展開。注意逆向任何商業(yè)平臺的接口都應僅用于學習研究、自有數據校驗等合規(guī)場景。對平臺接口的訪問需遵守其服務協議與相關法律法規(guī)控制請求頻率避免影響平臺正常運行本文不鼓勵對任何平臺進行惡意攻擊或大規(guī)模抓取。2. 抓包與sign參數定位在動手寫代碼之前第一步永遠是抓包。用Charles或Fiddler都行手機設置代理指向電腦安裝證書后就能看到客戶端發(fā)出的所有請求。我習慣用Charles因為它的斷點功能和Filter比Fiddler順手。抓包時有個細節(jié)一定先打開“SSL Proxying”SSL代理解密否則看到的是密文沒法分析。設置路徑在Proxy - SSL Proxying Settings勾選啟用并添加目標域名即可。iOS/Android端還需要安裝并信任Charles證書這一步容易卡住我后面在第三部分單獨講。目標接口很容易找到商品詳情頁往下滑價格區(qū)域刷新時會觸發(fā)一個名為goods_detail的請求URL后綴一般是api/xxx/goods/detail??此恼埱篌w參數大致如下{ goods_id: 1701234567890, timestamp: 1712345678, nonce: 8k3n2m9d, sign: f23a9c10b5d81e62e0a3140ca7f9d024 }其中goods_id是商品IDtimestamp是當前Unix時間戳秒級nonce是隨機字符串sign就是需要逆向破解的核心參數。這個sign的值看起來像MD532位十六進制但直接用參數拼接試過發(fā)現對不上——說明它加鹽salt了或者拼接規(guī)則沒那么簡單。定位思路這是一個標準的“搜索法”問題。直接用Chrome的DevTools開發(fā)者工具打開目標商品詳情頁在Sources面板里搜索sign字符串。搜索前記得把頁面所有JavaScript文件展開或者直接在Network面板選中某個JS文件后按CtrlShiftF做全局搜索。為了提高搜索命中率我一般會用一些跟簽名相關的特征詞比如signgoods_signgetSignnoncetimestamp搜索結果會列出所有包含這些關鍵字的JavaScript文件。逐一打開查找生成sign的位置。在我抓的這個案例里生成邏輯藏在一個叫network.js的文件里代碼結構大致長這樣var SIGN_KEY pdd_2024_internal_secure_key_8f3a; function _generateSign(params) { var keys Object.keys(params).sort(); var str ; for (var i 0; i keys.length; i) { if (keys[i] sign) continue; str keys[i] params[keys[i]] ; } str str.slice(0, -1); str SIGN_KEY; return md5(str); }看到了嗎邏輯就是把所有參數按字典序排序排除sign本身拼接成keyvaluekeyvalue的字符串去掉末尾的拼接上固定鹽值再取MD5。非常簡單但不知道鹽值的話憑猜是猜不出來的。這里有一個很強的經驗搜索sign字段時優(yōu)先看那些體積較小、命名像公共庫network/request/http的JS文件不要一上來就翻主業(yè)務JS。原因很簡單簽名模塊通常是獨立封裝的便于復用和維護不會混在業(yè)務代碼里。我一開始在商品詳情頁的大JS文件里翻了很久一無所獲換到network.js不到五分鐘就定位到了。3. JavaScript逆向斷點調試還原算法找到_generateSign函數后接下來要做的是確認它是不是真的被當前請求調用了以及參數細節(jié)是否和我推測的一致。這時候就要靠斷點調試了。Chrome的Sources面板里打開目標JS文件找到函數所在行打上斷點。然后重新觸發(fā)一次商品詳情頁的請求代碼就會在斷點處暫停。這時候在右側的Scope面板里能看到params對象的所有鍵值對包括goods_id、timestamp、nonce。確認無誤后單步執(zhí)行觀察str變量在每一步的變化——這一步非常關鍵因為能直接看到最終的拼接結果。我當時單步執(zhí)行時發(fā)現一個坑參數值里的字符串帶引號比如goods_id1701234567890但拼接時引號不會出現在字符串里。如果直接在Python里用json.dumps生成字符串復制過來拼出來的sign怎么都對不上。原因就是沒有在調試階段仔細看每一步的字符串。調試到md5(str)這行時可以在Console里手動執(zhí)行這個函數把str復制出來對比計算結果// 在Console中執(zhí)行 md5(goods_id1701234567890nonce8k3n2m9dtimestamp1712345678pdd_2024_internal_secure_key_8f3a)輸出f23a9c10b5d81e62e0a3140ca7f9d024對照抓包里的sign值完全一致。到這里簽名算法就徹底確認了。但事情沒這么簡單。我把目光轉到另一個字段——請求頭里的anti-content。這是一個每次請求都變化的動態(tài)Token服務端也會校驗。在Network面板里隨便選幾個請求對比發(fā)現anti-content長度固定、包含小寫字母和數字看起來像base64編碼后的結果。搜索定位到它生成于security.js文件代碼邏輯比sign復雜得多涉及一個自定義的_encryptData函數用到了AES加密密鑰通過另一個接口動態(tài)獲取。考慮到文章主題聚焦sign而且anti-content的破解方案與sign大同小異同樣是打斷點、還原算法這里不展開。重點強調一點在一個簽名算法上跑通流程后處理其他衍生參數會快得多因為框架和思路是通用的。關于工具選型也有點心得要分享Chrome DevTools主力工具搜索、斷點、查看調用棧都很方便。Fiddler/Charles抓包工具主要看請求與響應配合手機端調式。ReRes或油猴腳本如果需要在頁面里測試修改后的請求參數可以用這類工具做本地替換但這里不需要不展開。4. Python重構簽名與模擬請求簽名算法確認后就進入最枯燥也最容易翻車的階段用Python把它原樣復刻出來。核心思路是因為整個簽名邏輯只有“排序、拼接、加鹽、MD5”四步不需要Node.js環(huán)境直接Python一把梭。但如果遇到更復雜的AES或RSA加密建議用execjs庫直接調用JavaScript代碼省去翻譯的功夫也降低出錯的概率。我這會兒先用Python實現純邏輯版本方便排查。直接上代碼import hashlib import time import random import requests import string SIGN_KEY pdd_2024_internal_secure_key_8f3a def generate_sign(params: dict) - str: # 1. 剔除sign字段如果有的話 filtered {k: v for k, v in params.items() if k ! sign} # 2. 按鍵名進行字典序排序 sorted_keys sorted(filtered.keys()) # 3. 拼接 keyvalue 形式 raw_str .join([f{k}{filtered[k]} for k in sorted_keys]) # 4. 拼接鹽值 raw_str SIGN_KEY # 5. 計算MD5 return hashlib.md5(raw_str.encode(utf-8)).hexdigest() def build_params(goods_id: str) - dict: timestamp str(int(time.time())) nonce .join(random.choices(string.ascii_letters string.digits, k8)) params { goods_id: goods_id, timestamp: timestamp, nonce: nonce, } params[sign] generate_sign(params) return params def fetch_price(goods_id: str) - float: params build_params(goods_id) headers { User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15, Referer: https://mobile.yangkeduo.com/goods.html?goods_id goods_id, Content-Type: application/json; charsetutf-8, } url https://mobile.yangkeduo.com/proxy/api/api/xxxx/goods/detail resp requests.post(url, jsonparams, headersheaders, timeout5) data resp.json() if data.get(error_code) 0: # 假設價格字段在 min_group_price 里單位是分換算成元 price_fen data[result][goods][min_group_price] return price_fen / 100 else: raise ValueError(f接口返回異常: {data.get(error_msg)})這里有幾個細節(jié)要特別說明都是我沒日沒夜試出來的經驗1. 排序必須是字符序的升序但不要忽略類型問題Python的sorted默認按unicode碼點排序數字和字母混排的時候要小心。建議把所有值先str()轉成字符串再進入排序和拼接不然遇到timestamp為整數、goods_id為字符串時拼接結果會和JavaScript側不一致。我在第一版代碼里直接用了原始字典結果排在后面的參數拼接順序不固定前五個請求全被拒絕。2. 拼接時不要出現多余空格和引號最常見的錯誤是直接把json.dumps(params)的結果拿來做拼接結果字符串里多出了和空格。JavaScript側的params[key]是原始值不帶引號。這個錯能卡你一整天一定要在調試階段確認每一次拼接后的字符串長什么樣。3.timestamp必須與服務器時間對齊這個平臺會校驗timestamp與服務器時間的偏差超過5分鐘直接拒絕。如果你的本機時間不準或者用了虛擬機導致時鐘偏移請求沒進到簽名校驗就掛了。解決方案很簡單寫一個get_server_time()函數先調一個公開時間接口校準再計算簽名。def get_server_time(): resp requests.get(https://api.m.taobao.com/rest/api3.do?apimtop.common.getTimestamp, timeout3) return int(resp.json()[data][t]) // 1000然后把build_params里的timestamp改成str(get_server_time())確保誤差在秒級內。4. 請求頭里的Referer必須對應跳轉前的頁面這個平臺的接口做了防盜鏈校驗Referer和請求參數不匹配時會被攔截在風控層返回-1而不是-15排查起來很迷惑。務必把Referer設置為“從商品列表頁點擊進入詳情頁”時對應的地址。5. Cookie不是必須的但帶著更穩(wěn)定實測同一個IP、不帶Cookie也能請求成功但頻率一高比如超過每分鐘30次就會觸發(fā)驗證碼。帶上登錄后的Cookie能有效提升請求上限。但千萬別用別人的Cookie去搞大規(guī)模抓取賬號安全與合規(guī)問題都是雷區(qū)。寫完第一版代碼后跑了一次測試商品ID: 1701234567890 返回價格: 19.90 元 耗時: 0.32 秒單次請求成功說明sign算法完整復刻無誤。但這只是萬里長征第一步更高的挑戰(zhàn)在后面——頻率控制和IP池。5. 踩坑實錄高頻請求下風控觸發(fā)與繞行策略爬蟲從來不缺代碼缺的是“能穩(wěn)定跑三天不掛”的代碼。我在簽名算法跑通后的第二天就遇到了高頻請求下的風控問題。剛開始很順利用單線程跑每分鐘大約請求10次持續(xù)半小時沒問題。我把循環(huán)改成多線程10個線程并發(fā)結果不到1分鐘所有請求開始返回-15。這個錯誤碼的含義是“簽名不合法或被風控攔截”但我代碼里的簽名邏輯沒變過唯一的變量是并發(fā)數量。原因很清楚單IP高頻請求導致IP被風控標記簽名校驗的后置邏輯直接拒絕所有請求不管簽名是否正確。我試過幾個排錯步驟按有效程度排序降低并發(fā)數增加隨機延遲把10個線程降到3個每個請求之間隨機睡眠2-5秒。穩(wěn)定運行大約20分鐘仍然會觸發(fā)風控但頻率大幅下降。切換IP代理池當時我手頭有50個小號代理IP全部走HTTP代理每次請求隨機切換。配合2秒的延遲單IP請求間隔平均超過100秒終于穩(wěn)定跑過了2小時。模擬真實操作路徑直接在請求前調用一個商品列表頁拿到列表數據后再請求詳情頁。這個策略能明顯降低風控命中率因為用戶正常的操作路徑是“先列表后詳情”直接單飛詳情頁在風控模型里屬于異常行為。綜合策略代碼大致如下import time import random from proxypool import ProxyPool proxy_pool ProxyPool(http://your-proxy-pool:8080) def fetch_with_retry(goods_id, max_retries3): for attempt in range(max_retries): url https://mobile.yangkeduo.com/proxy/api/api/xxxx/goods/detail proxy proxy_pool.get_random_proxy() headers { User-Agent: random.choice(UA_LIST), Referer: fhttps://mobile.yangkeduo.com/goods.html?goods_id{goods_id}, Content-Type: application/json, } try: resp requests.post( url, jsonbuild_params(goods_id), headersheaders, proxies{http: proxy, https: proxy}, timeout8 ) data resp.json() if data.get(error_code) -15: print(f[重試] 觸發(fā)風控更換IP重試第{attempt1}次) time.sleep(random.uniform(3, 6)) continue return data except Exception as e: time.sleep(random.uniform(1, 3)) raise RuntimeError(超過最大重試次數)這里有個重點不是所有失敗都值得重試。如果返回的是-15說明可能是IP風控切換IP還有救。但如果返回的是-1參數錯誤或-7商品不存在重試一萬次也沒用反而會因為無效請求加重被風控的風險。我在代碼里做了錯誤碼分類只有特定錯誤碼才進入重試邏輯。另一個容易忽略的點是請求頻率的波動性。不要以為“每分鐘30次”就是安全閾值風控模型會看“短時間內的請求方差”。如果你前30秒集中發(fā)20個請求接下來30秒一個請求都沒有這種“突發(fā)空閑”的模式比均勻的每秒1個請求更容易觸發(fā)風控。所以我在實現里用的是指數退避抖動的策略基礎間隔2秒實際間隔在1.5秒到4秒之間浮動讓請求間隔看起來像人工操作。下表匯總了我遇到的典型問題與處理結果方便排查時對照錯誤碼/現象可能原因處理方案-15 簽名校驗失敗IP被風控標記切換IP、降低頻率、模擬瀏覽路徑-1 參數錯誤sign拼接時多了空格或引號回到JavaScript調試逐行比對拼接字符串-7 商品不存在goods_id無效或已下架換一個測試商品ID-9 接口內部錯誤請求頭缺失Referer或UA補全Headers特別是Referer對應來源頁超時異常代理IP不穩(wěn)定改用高可用代理池設置超時重試返回HTML而非JSON觸發(fā)驗證碼跳轉停止當前IP冷卻30分鐘后再試在這些問題里**最具隱蔽性的是“簽名正確但請求仍被拒絕”**的情況。它意味著接口不只校驗簽名還會分析請求的行為特征。我在排查到這一步時一度以為簽名算法有隱藏更新后來通過對比“瀏覽器發(fā)出的完整請求”和“Python發(fā)出的請求”發(fā)現差異在Header順序和HTTP版本HTTP/2 vs HTTP/1.1。這個平臺對HTTP/2的指紋識別比HTTP/1.1寬松換成HTTP/2后風控率明顯下降。在Python里啟用HTTP/2需要借助httpx庫我實測換用httpx后穩(wěn)定度提升顯著import httpx client httpx.Client( http2True, headersheaders, proxieshttp://your-proxy:8080, timeout10 ) resp client.post(url, jsonparams)只要服務器支持HTTP/2這個切換能直接改善被風控的情況。但要留意httpx的代理參數比requests更嚴格格式上要寫完整的http://ip:port否則會報ProxyError。6. 批量抓取的任務隊列設計單個接口跑通后面對的是幾十甚至幾百萬個商品ID。這時候不能再同步請求了必須設計一個輕量級的任務隊列。我沒有直接引入Celery理由是項目體量不大引入消息隊列有點殺雞用牛刀。用Python自帶的concurrent.futures加一個簡單的隊列即可import concurrent.futures import queue import threading task_queue queue.Queue() result_dict {} result_lock threading.Lock() # 填充任務 for gid in goods_id_list: task_queue.put(gid) # 消費者線程 def worker(): while not task_queue.empty(): gid task_queue.get() try: price fetch_with_retry(gid) with result_lock: result_dict[gid] price except Exception as e: with result_lock: result_dict[gid] ferror: {e} finally: task_queue.task_done() with concurrent.futures.ThreadPoolExecutor(max_workers3) as executor: for _ in range(3): executor.submit(worker) task_queue.join() print(result_dict)這里最核心的參數是max_workers。根據我的實測3個并發(fā)是比較安全的數值既能把抓取速度提上來又不至于瞬間觸發(fā)風控。如果IP池質量足夠高比如擁有幾百個干凈代理可以適當增加到5個但不建議超過8個邊際收益很低風險卻直線上升。數據落地我直接用的CSV因為價格數據本身是結構化的沒必要上數據庫。但如果后續(xù)要增量更新比如每天跑一遍全部商品的價格CSV的讀寫就會變成瓶頸——此時建議切到SQLite或MySQL用商品ID做唯一索引便于更新和去重。一個特別值得推薦的細節(jié)任務做完后把成功的商品ID保存到單獨的清單里下次只抓失敗的。這樣可以少請求幾千次對風控友好也節(jié)約時間成本。7. 常見問題與排查技巧實錄按照慣例把整個流程中遇到的高頻問題整理成速查表方便卡殼時直接翻問題排查步驟解藥sign值一直校驗失敗1. 抓包對比sign是否正確 2. 在JS斷點里手動執(zhí)行md5驗證 3. 檢查Python腳本中字符串拼接是否多引號回到調試環(huán)境逐行比對請求返回-151. 確認IP是否已被風控換瀏覽器訪問驗證2. 檢查請求頻率 3. 檢查HTTP版本換IP、降頻、升級到HTTP/2抓包看不到HTTPS明文1. 是否安裝并信任Charles證書 2. 是否開啟SSL Proxying 3. iOS還需在“關于本機”里信任證書描述文件按證書安裝教程重來搜索sign關鍵字搜不到1. 嘗試搜索md5、Hmac、sha256、encrypt等加密特征詞 2. 搜索salt、key等拼接關鍵字 3. 檢查JS加載是否完整換關鍵詞全局搜索接口返回HTML而非JSON大概率是觸發(fā)了驗證碼跳轉立刻暫停該IP等待冷卻期后續(xù)降低并發(fā)價格字段解析不到檢查返回JSON的層級結構不同商品類型字段位置不同打印原始JSON再定位字段路徑另外有一個很容易被忽略的“隱藏雷區(qū)”JavaScript文件版本會更新。平臺方不定期會修改加密邏輯比如更換鹽值、調整參數的排序規(guī)則或者把MD5換成了HMAC。所以編寫代碼時為將來做好更新預案非常重要。我的做法是寫了一個簡單的“算法配置化”結構SIGN_CONFIG { version: 2024.11, salt: pdd_2024_internal_secure_key_8f3a, hash_algo: md5, param_sort: ascii, exclude_keys: [sign], }這樣如果遇到鹽值更新只需改配置不需要改主邏輯。如果是算法結構調整比如增加了時間戳偏移就需要回調試環(huán)境再走一遍全流程了。排查問題還有一個通用心得永遠先做最小化驗證。不要帶一整套Headers和并發(fā)邏輯去調試簽名。先用一個最簡單的requests.get加上已算好的參數、一個固定的手機UA確認是否能拿到正常JSON。如果最小請求都不過大概率是簽名問題如果最小請求過了再逐步加Headers和并發(fā)就能精準定位是哪一步觸發(fā)了風控。這個“加法排錯法”能省掉很多無用功。8. 逆向工程的邊界意識與合規(guī)思考最后這部分寫給我也是寫給所有在做同類事情的同行。逆向一個平臺的簽名算法技術上講確實很“酸爽”有一種解謎成功的快感。但從職業(yè)操守和合規(guī)的角度看邊界感必須清醒。研究目的 vs 商業(yè)利用為了學習加密與反爬機制、做安全研究、驗證自身系統的安全性這條路沒有任何問題。但如果拿著破解后的簽名去大規(guī)模獲取平臺數據用于商業(yè)經營那可能涉及不正當競爭甚至更嚴重的法律風險。自身數據 vs 他人數據抓取自己店鋪的價格、庫存、訂單數據是合理的數據管理需求。但抓取競爭對手的價格策略并用于惡意壓價就明顯踩線了。請求頻率紅線任何平臺的后端資源都是有限的高頻率請求不只是法律層面的問題也會拖垮平臺的正常服務體驗。把單IP的請求間隔控制在合理范圍是每個爬蟲開發(fā)者都應自覺做到的。所以我個人在實際項目里的做法是只對自有商品或授權范圍內的商品做價格校驗數據僅用于內部決策不對外公開。同時在代碼里默認加入全局限速邏輯固定頻率上限而不是等到被風控了再去降速。如果你是新接觸逆向的開發(fā)者我的建議是先在實驗環(huán)境比如自己搭的模擬接口里跑通這套流程理解了“抓包-定位-調試-重構”的方法論后再接觸真實平臺。方法論永遠比單一破解方案值錢因為平臺方更新算法時你的方法論能幫你快速適應新的挑戰(zhàn)。那天夜里我盯著終端里連續(xù)二十個請求全部返回200時有一種“通關”的感覺。但比通關更有價值的是過程中積累的那張“避坑清單”——簽名拼接沒有引號、時間戳要對準服務器、風控不只是看簽名本身、HTTP/2能繞開一部分指紋檢測。這些經驗在任何一個平臺遇到類似的簽名機制時都能復用上。這也是我寫這篇長文的原因把路蹚平后面走的人就能少踩點坑。