Hikvision 考勤机数据提取

海康威视的某考勤机可以根据网上的 api 接口提取数据,而不必用优盘拷贝。
经过观察,有如下的流程——

graph TD A[开始] --> B[Step 1: GET sessionLogin/capabilities with random] B --> C[Step 2: POST sessionLogin with timestamp] C --> D[Step 3: PUT sessionHeartbeat] D --> E[Step 4: GET Security/capabilities] E --> F[Step 5: 计算固定AES密钥] F --> G[Step 6: GET Security/users 验证密钥] G --> H[Step 7: POST AcsEvent 获取数据] H --> I[Step 8: AES-CBC + Base64 解密] I --> J[保存为CSV或JSON] J --> K[结束]

考勤等数据存在 AES CBC 加密和 Base64 编码。
aes key是固定不变的,可以调试得到(或者由固定的盐值按算法计算而来)
image
image
image

image
searchId 可以选择某个固定的值,或者使用 uuidv4

image
image

#!/usr/bin/env python3
"""
Hikvision Attendance Data Extractor (Cleaned & Enhanced)
- Uses fixed AES key
- Splits ISO8601 time into 'date' and 'time' (HH:MM:SS)
- Removes redundant comments/logic
"""

import json
import binascii
import base64
import hashlib
import time
import re
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

# ------------------- Config -------------------
HOST = "192.168.1.192"
USERNAME = "admin"
PASSWORD = "password"
BASE_URL = f"http://{HOST}"
AES_KEY = "90c5ba363d3a4d7db66d2564c5697319"  # 32 hex chars = 16 bytes

session = requests.Session()

# ------------------- Auth -------------------
def login():
    # Step 1: Get session challenge
    random_param = str(int(hashlib.md5(str(time.time()).encode()).hexdigest()[:8], 16))[:8]
    cap_url = f"{BASE_URL}/ISAPI/Security/sessionLogin/capabilities?username={USERNAME}&random={random_param}"
    try:
        resp = session.get(cap_url, timeout=10)
        resp.raise_for_status()
        session_id = re.search(r"<sessionID>(\w+)</sessionID>", resp.text).group(1)
        challenge = re.search(r"<challenge>([a-f0-9]+)</challenge>", resp.text).group(1)
        iterations = int(re.search(r"<iterations>(\d+)</iterations>", resp.text).group(1))
        salt = re.search(r"<salt>(\w+)</salt>", resp.text).group(1)
    except Exception as e:
        print(json.dumps({"error": f"Get capabilities failed: {e}"}))
        return None

    # Step 2: Compute password hash
    pwd = hashlib.sha256((USERNAME + salt + PASSWORD).encode()).hexdigest()
    pwd = hashlib.sha256((pwd + challenge).encode()).hexdigest()
    for _ in range(2, iterations):
        pwd = hashlib.sha256(pwd.encode()).hexdigest()

    # Step 3: Login
    login_xml = f"""<SessionLogin>
    <userName>{USERNAME}</userName>
    <password>{pwd}</password>
    <sessionID>{session_id}</sessionID>
    <isSupportSessionTag>false</isSupportSessionTag>
    <isSessionIDValidLongTerm>false</isSessionIDValidLongTerm>
    <sessionIDVersion>2</sessionIDVersion>
</SessionLogin>"""
    
    ts = int(time.time() * 1000)
    login_url = f"{BASE_URL}/ISAPI/Security/sessionLogin?timeStamp={ts}"
    try:
        resp = session.post(login_url, data=login_xml, timeout=10)
        if "<statusValue>200</statusValue>" not in resp.text:
            print(json.dumps({"error": "Login failed"}))
            return None
        return ts
    except Exception as e:
        print(json.dumps({"error": f"Login error: {e}"}))
        return None

# ------------------- Decrypt -------------------
def aes_decrypt_base64(ciphertext_hex, key_hex, iv_hex):
    if not ciphertext_hex:
        return ""
    try:
        key = binascii.unhexlify(key_hex)
        iv = binascii.unhexlify(iv_hex)
        ct = binascii.unhexlify(ciphertext_hex)
        cipher = AES.new(key, AES.MODE_CBC, iv)
        decrypted = unpad(cipher.decrypt(ct), AES.block_size)
        b64_str = decrypted.decode('utf-8')
        return base64.b64decode(b64_str).decode('utf-8')
    except Exception:
        return ciphertext_hex  # fallback to original if decryption fails

# ------------------- Fetch -------------------
def fetch_attendance(start_time, end_time, login_ts):
    iv = hashlib.md5(str(login_ts).encode()).hexdigest()
    url = f"{BASE_URL}/ISAPI/AccessControl/AcsEvent?format=json&security=1&iv={iv}"
    records = []
    position = 0

    while True:
        payload = {
            "AcsEventCond": {
                "searchID": "c6b8f41a-d75a-4e43-a0c9-8e032f636bb1",
                "searchResultPosition": position,
                "maxResults": 24,
                "major": 0,
                "minor": 0,
                "startTime": start_time,
                "endTime": end_time,
            }
        }

        try:
            resp = session.post(url, json=payload, timeout=30)
            resp.raise_for_status()
            data = resp.json()
            info_list = data.get("AcsEvent", {}).get("InfoList", [])
            if not info_list:
                break

            for item in info_list:
                name_enc = item.get("name", "")
                emp_enc = item.get("employeeNoString", "")
                time_raw = item.get("time", "")

                # Decrypt fields if long (likely encrypted)
                name = aes_decrypt_base64(name_enc, AES_KEY, iv) if len(name_enc) > 20 else name_enc
                emp_no = aes_decrypt_base64(emp_enc, AES_KEY, iv) if len(emp_enc) > 20 else emp_enc

                # Split ISO8601 time: "2025-10-09T08:16:09+08:00" → date="2025-10-09", time="08:16:09"
                date_part = time_part = ""
                if time_raw:
                    if "T" in time_raw:
                        dt_part = time_raw.split("T")[0]
                        t_part = time_raw.split("T")[1]
                        time_part = t_part.split("+")[0].split(".")[0]  # Remove timezone and millis
                        date_part = dt_part
                    else:
                        date_part = time_part = time_raw

                records.append({
                    "employeeNo": emp_no,
                    "name": name,
                    "date": date_part,
                    "time": time_part,
                    "cardNo": item.get("cardNo", "")
                })

            position += len(info_list)
            total = data.get("AcsEvent", {}).get("totalMatches", 0)
            if position >= total:
                break

        except Exception as e:
            print(json.dumps({"error": f"Fetch error: {e}"}))
            break

    return records

# ------------------- Main -------------------
def main():
    login_ts = login()
    if not login_ts:
        return

    start = "2025-10-01T00:00:00+08:00"
    end = "2025-10-31T23:59:59+08:00"
    records = fetch_attendance(start, end, login_ts)

    with open("attendance_simple.json", "w", encoding="utf-8") as f:
        json.dump(records, f, ensure_ascii=False, indent=2)
    print(f"✅ Successfully saved {len(records)} records to attendance_simple.json")

if __name__ == "__main__":
    main()

参考阅读:
海康摄像头Web登陆算法及过程 - 刚先生
记录一下项目中对接海康设备时使用ISAPI的一些经验
海康api 签名 认证 demo 海康摄像头认证方式basic
调用认证
利用Python调用海康威视综合管理平台openAPI接口
常见问题解决 --- 海康OpenAPI安全认证库的demo运行报错

posted @ 2025-11-25 19:31  geyee  阅读(40)  评论(0)    收藏  举报