สแกมที่พบบ่อยที่สุดในระบบเติมเงินคือ การส่งสลิปโอนเงินใบเดิมซ้ำหลายครั้ง หรือแก้ภาพเล็กน้อย แล้วส่งใหม่ เป้าหมายของโมดูลนี้คือสร้าง “ลายนิ้วมือของภาพ” ที่เปลี่ยนนิดเดียวเมื่อภาพเปลี่ยนนิดเดียว เพื่อจับสลิปซ้ำก่อนจะเครดิตเงินเข้าบัญชี
ปัญหา: สลิปซ้ำ = เงินหาย
ผู้โจมตีถ่ายหน้าจอสลิปเดียวกัน ครอปขอบ ใส่ filter หรือบีบอัด JPEG ใหม่ แล้วส่งซ้ำ ระบบที่เช็กแค่ “เลขอ้างอิงซ้ำ” พลาดได้ถ้าผู้โจมตีเบลอเลขอ้างอิงทิ้ง เราต้องการตัวจับที่ดูที่ “ภาพ” โดยตรง และทนต่อการเปลี่ยนแปลงเล็ก ๆ น้อย ๆ
ทำไมไม่ใช้ MD5/SHA
cryptographic hash อย่าง SHA-256 ถูกออกแบบให้ avalanche: เปลี่ยน 1 บิตของ input ทำให้ output เปลี่ยนทั้งหมด ดีสำหรับความปลอดภัย แต่ไร้ประโยชน์สำหรับการเทียบความคล้าย เพราะ บีบอัด JPEG ใหม่ทีเดียว hash ก็เปลี่ยนหมด perceptual hash ทำตรงข้าม: ภาพที่ตาคนมองว่าเหมือนกัน ต้องได้ hash ที่ใกล้กัน
dHash: คำนวณ fingerprint
dHash (difference hash) ทำงานบนหลักว่า “ทิศทางความสว่างระหว่างพิกเซลข้างเคียง” ทนต่อการปรับ brightness/contrast ขั้นตอน: ย่อภาพเป็น 9×8 เป็น grayscale แล้วเทียบพิกเซลซ้ายกับขวา ได้ 64 บิต เร็วและเสถียร
1from PIL import Image23def dhash(image: Image.Image, hash_size: int = 8) -> int:4 # ย่อเป็น (hash_size+1) x hash_size แล้วแปลงเป็นเทา5 gray = image.convert("L").resize(6 (hash_size + 1, hash_size), Image.Resampling.LANCZOS7 )8 pixels = list(gray.getdata())9 width = hash_size + 11011 bits = 012 for row in range(hash_size):13 for col in range(hash_size):14 left = pixels[row * width + col]15 right = pixels[row * width + col + 1]16 bits = (bits << 1) | int(left > right)17 return bits # 64-bit integer1819def dhash_hex(image: Image.Image) -> str:20 return format(dhash(image), "016x")pHash: ทนต่อการแก้ภาพ
pHash (perceptual hash) ใช้ Discrete Cosine Transform (DCT) ดึงเฉพาะความถี่ต่ำซึ่งเก็บโครงสร้างหลักของภาพ ทนต่อ gamma, การปรับสี และการย่อขยายได้ดีกว่า dHash เหมาะกับสลิปที่ถูก re-compress หนัก ๆ
1import numpy as np2from PIL import Image3from scipy.fftpack import dct45def phash(image: Image.Image, hash_size: int = 8, highfreq_factor: int = 4) -> int:6 img_size = hash_size * highfreq_factor # 32x327 gray = image.convert("L").resize(8 (img_size, img_size), Image.Resampling.LANCZOS9 )10 pixels = np.asarray(gray, dtype=np.float32)1112 # DCT 2 มิติ แล้วเก็บมุมบนซ้าย (ความถี่ต่ำ)13 coeffs = dct(dct(pixels, axis=0, norm="ortho"), axis=1, norm="ortho")14 low = coeffs[:hash_size, :hash_size]1516 # ใช้ median ของบล็อก (ข้าม DC term ที่ [0,0] เพื่อตัด bias ความสว่างรวม)17 med = np.median(low[1:].flatten())1819 bits = 020 for value in low.flatten():21 bits = (bits << 1) | int(value > med)22 return bitsHamming distance และ threshold
ความใกล้ของสอง hash วัดด้วย Hamming distance: จำนวนบิตที่ต่างกัน ภาพเดียวกันมักได้ระยะ 0-5 ภาพคนละใบมักได้ระยะ 25+ ตั้ง threshold ราว 8-10 บิต (จาก 64) เป็นจุดเริ่มที่ดี แล้วปรับด้วยข้อมูลจริง
1def hamming(a: int, b: int) -> int:2 return (a ^ b).bit_count() # Python 3.10+34DUP_THRESHOLD = 8 # <= ถือว่าเป็นสลิปเดียวกัน56def is_duplicate(hash_a: int, hash_b: int) -> bool:7 return hamming(hash_a, hash_b) <= DUP_THRESHOLD1import io2from PIL import Image34class DuplicateSlipError(Exception):5 def __init__(self, existing_id: str, distance: int):6 self.existing_id = existing_id7 self.distance = distance8 super().__init__(f"ซ้ำกับสลิป {existing_id} (distance={distance})")910async def verify_slip(raw_bytes: bytes, repo) -> int:11 """คืนค่า hash ถ้าผ่าน, โยน error ถ้าซ้ำ - เรียกก่อนเครดิตเงินเสมอ"""12 image = Image.open(io.BytesIO(raw_bytes))13 h = phash(image)1415 # ดึงเฉพาะ candidate ที่ใกล้ (ดูหัวข้อ BK-tree) แล้วเทียบ16 for existing_id, existing_hash in await repo.candidates(h):17 dist = hamming(h, existing_hash)18 if dist <= DUP_THRESHOLD:19 raise DuplicateSlipError(existing_id, dist)20 return hค้นหาเร็วบนล้านสลิป (BK-tree)
การเทียบ hash ใหม่กับทุก hash เดิมแบบ brute force คือ O(n) ซึ่งช้าเมื่อมีหลายล้านสลิป BK-tree เป็น โครงสร้างข้อมูลที่ใช้คุณสมบัติ triangle inequality ของ Hamming distance เพื่อตัดกิ่งที่เป็นไปไม่ได้ทิ้ง ทำให้ค้นในระดับ log
1class BKTree:2 def __init__(self, distance_fn):3 self.distance = distance_fn4 self.root = None # (value, payload, {dist: child})56 def add(self, value: int, payload):7 if self.root is None:8 self.root = (value, payload, {})9 return10 node = self.root11 while True:12 d = self.distance(value, node[0])13 child = node[2].get(d)14 if child is None:15 node[2][d] = (value, payload, {})16 return17 node = child1819 def query(self, value: int, max_dist: int):20 if self.root is None:21 return []22 results, stack = [], [self.root]23 while stack:24 v, payload, children = stack.pop()25 d = self.distance(value, v)26 if d <= max_dist:27 results.append((payload, d))28 # triangle inequality: เหลือเฉพาะกิ่งที่ระยะอยู่ในช่วงที่เป็นไปได้29 for edge, child in children.items():30 if d - max_dist <= edge <= d + max_dist:31 stack.append(child)32 return resultsเช็กลิสต์ production
- คำนวณทั้ง pHash และ dHash เก็บไว้ทั้งคู่ ใช้ร่วมกันเพิ่มความมั่นใจ
- ตรวจซ้ำภายใน DB transaction เดียวกับการเครดิตเงิน + lock ระดับบัญชี
- เก็บ raw image อย่างปลอดภัยไว้ระยะหนึ่งเพื่อ audit และฝึก threshold ใหม่
- มี allowlist สำหรับเคสซ้ำที่ถูกต้อง (เช่น ผ่อนชำระงวดเดิมจริง) ให้คนปลดล็อกได้
- เฝ้าดู false-positive rate อย่าให้ลูกค้าจริงโดนบล็อกเพราะ threshold แคบเกิน
สรุปสำคัญ
- อย่าใช้ SHA/MD5 จับภาพซ้ำ มันเปลี่ยนทั้งหมดเมื่อ re-compress ภาพเพียงครั้งเดียว
- วัดความใกล้ของ hash ด้วย Hamming distance ตั้ง threshold ราว 8-10 บิตจาก 64
- ตรวจซ้ำภายใน transaction เดียวกับการเครดิตเงิน + lock ระดับบัญชี กัน race condition
ควิซท้ายบท
01ทำไมจึงห้ามใช้ cryptographic hash อย่าง SHA-256 เพื่อจับภาพสลิปซ้ำ
02Hamming distance ระหว่างสอง perceptual hash หมายถึงอะไร
03pHash ใช้ Discrete Cosine Transform (DCT) แล้วเก็บเฉพาะความถี่ต่ำ เพราะเหตุใด
04ทำไม BK-tree จึงค้นหา hash ที่ใกล้เคียงได้เร็วกว่าการเทียบทุกตัวแบบ brute force