225 lines
5.1 KiB
Go
225 lines
5.1 KiB
Go
package analysis
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/andydixon/nexus/internal/leviathan/document"
|
|
)
|
|
|
|
// fuzzy.go implements context-triggered piecewise hashing (CTPH) compatible
|
|
// with ssdeep. CTPH produces a short signature of the form
|
|
// "blocksize:hash1:hash2" that stays similar when the input changes only a
|
|
// little, so investigators can cluster related samples or match a file
|
|
// against a known-bad ssdeep hash even after minor edits.
|
|
//
|
|
// The signature format and the rolling/sum hashes follow Andrew Tridgell's
|
|
// spamsum / Jesse Kornblum's ssdeep so the output interoperates with the
|
|
// standard tooling (e.g. an empty input hashes to "3::").
|
|
|
|
const (
|
|
spamsumLength = 64
|
|
rollingWindow = 7
|
|
minBlocksize = 3
|
|
hashInit = uint32(0x28021967)
|
|
hashPrime = uint32(0x01000193)
|
|
)
|
|
|
|
const b64alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
|
|
type rollState struct {
|
|
h1, h2, h3 uint32
|
|
window [rollingWindow]byte
|
|
n int
|
|
}
|
|
|
|
func (r *rollState) roll(c byte) uint32 {
|
|
r.h2 -= r.h1
|
|
r.h2 += rollingWindow * uint32(c)
|
|
r.h1 += uint32(c)
|
|
r.h1 -= uint32(r.window[r.n%rollingWindow])
|
|
r.window[r.n%rollingWindow] = c
|
|
r.n++
|
|
r.h3 = r.h3 << 5
|
|
r.h3 ^= uint32(c)
|
|
return r.h1 + r.h2 + r.h3
|
|
}
|
|
|
|
func sumHash(c byte, h uint32) uint32 { return (h * hashPrime) ^ uint32(c) }
|
|
|
|
// FuzzyDigest computes the ssdeep-compatible signature for data.
|
|
func FuzzyDigest(data []byte) string {
|
|
bs := uint32(minBlocksize)
|
|
for uint64(bs)*spamsumLength < uint64(len(data)) {
|
|
bs *= 2
|
|
}
|
|
for {
|
|
sig1, sig2 := ctphSigs(data, bs)
|
|
if bs > minBlocksize && len(sig1) < spamsumLength/2 {
|
|
bs /= 2
|
|
continue
|
|
}
|
|
return fmt.Sprintf("%d:%s:%s", bs, sig1, sig2)
|
|
}
|
|
}
|
|
|
|
func ctphSigs(data []byte, bs uint32) (string, string) {
|
|
var rs rollState
|
|
h1, h2 := hashInit, hashInit
|
|
var sig1, sig2 strings.Builder
|
|
var lastRoll uint32
|
|
|
|
for _, c := range data {
|
|
h1 = sumHash(c, h1)
|
|
h2 = sumHash(c, h2)
|
|
rh := rs.roll(c)
|
|
lastRoll = rh
|
|
|
|
if rh%bs == bs-1 && sig1.Len() < spamsumLength-1 {
|
|
sig1.WriteByte(b64alphabet[h1%64])
|
|
h1 = hashInit
|
|
}
|
|
if rh%(bs*2) == bs*2-1 && sig2.Len() < spamsumLength/2-1 {
|
|
sig2.WriteByte(b64alphabet[h2%64])
|
|
h2 = hashInit
|
|
}
|
|
}
|
|
if lastRoll != 0 {
|
|
sig1.WriteByte(b64alphabet[h1%64])
|
|
sig2.WriteByte(b64alphabet[h2%64])
|
|
}
|
|
return sig1.String(), sig2.String()
|
|
}
|
|
|
|
// FuzzyHash computes the CTPH signature over a document range.
|
|
func FuzzyHash(d *document.Document, start, length int64) (map[string]any, error) {
|
|
if length < 0 {
|
|
length = d.Size() - start
|
|
}
|
|
buf, err := d.Range(start, length)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sig := FuzzyDigest(buf)
|
|
bs := strings.SplitN(sig, ":", 2)[0]
|
|
return map[string]any{
|
|
"algo": "ssdeep",
|
|
"signature": sig,
|
|
"blocksize": bs,
|
|
"length": length,
|
|
}, nil
|
|
}
|
|
|
|
// FuzzyCompare returns a 0-100 similarity score between two ssdeep
|
|
// signatures. 0 means unrelated (or incomparable block sizes); 100 means the
|
|
// signatures are identical.
|
|
func FuzzyCompare(a, b string) int {
|
|
bs1, a1, a2, ok1 := parseSig(a)
|
|
bs2, b1, b2, ok2 := parseSig(b)
|
|
if !ok1 || !ok2 {
|
|
return 0
|
|
}
|
|
if bs1 != bs2 && bs1 != bs2*2 && bs2 != bs1*2 {
|
|
return 0
|
|
}
|
|
switch {
|
|
case bs1 == bs2:
|
|
s1 := scoreStrings(a1, b1, bs1)
|
|
s2 := scoreStrings(a2, b2, bs1*2)
|
|
if s2 > s1 {
|
|
return s2
|
|
}
|
|
return s1
|
|
case bs1 == bs2*2:
|
|
return scoreStrings(a1, b2, bs1)
|
|
default: // bs2 == bs1*2
|
|
return scoreStrings(a2, b1, bs2)
|
|
}
|
|
}
|
|
|
|
func parseSig(s string) (uint32, string, string, bool) {
|
|
parts := strings.SplitN(s, ":", 3)
|
|
if len(parts) != 3 {
|
|
return 0, "", "", false
|
|
}
|
|
bs, err := strconv.ParseUint(parts[0], 10, 32)
|
|
if err != nil || bs == 0 {
|
|
return 0, "", "", false
|
|
}
|
|
return uint32(bs), parts[1], parts[2], true
|
|
}
|
|
|
|
func scoreStrings(s1, s2 string, blocksize uint32) int {
|
|
if !hasCommonSubstring(s1, s2, rollingWindow) {
|
|
return 0
|
|
}
|
|
if s1 == s2 {
|
|
return 100
|
|
}
|
|
d := editDistance(s1, s2)
|
|
if len(s1)+len(s2) == 0 {
|
|
return 0
|
|
}
|
|
score := (d * spamsumLength) / (len(s1) + len(s2))
|
|
score = (100 * score) / spamsumLength
|
|
score = 100 - score
|
|
// Small block sizes describe little data, so cap the score they can claim.
|
|
cap := int(blocksize) / minBlocksize * minInt(len(s1), len(s2))
|
|
if score > cap {
|
|
score = cap
|
|
}
|
|
if score > 100 {
|
|
score = 100
|
|
}
|
|
if score < 0 {
|
|
score = 0
|
|
}
|
|
return score
|
|
}
|
|
|
|
func hasCommonSubstring(s1, s2 string, n int) bool {
|
|
if len(s1) < n || len(s2) < n {
|
|
return false
|
|
}
|
|
seen := make(map[string]struct{}, len(s1))
|
|
for i := 0; i+n <= len(s1); i++ {
|
|
seen[s1[i:i+n]] = struct{}{}
|
|
}
|
|
for i := 0; i+n <= len(s2); i++ {
|
|
if _, ok := seen[s2[i:i+n]]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// editDistance is classic Levenshtein with unit insert/delete/substitute cost.
|
|
func editDistance(s1, s2 string) int {
|
|
m, n := len(s1), len(s2)
|
|
prev := make([]int, n+1)
|
|
cur := make([]int, n+1)
|
|
for j := 0; j <= n; j++ {
|
|
prev[j] = j
|
|
}
|
|
for i := 1; i <= m; i++ {
|
|
cur[0] = i
|
|
for j := 1; j <= n; j++ {
|
|
cost := 1
|
|
if s1[i-1] == s2[j-1] {
|
|
cost = 0
|
|
}
|
|
cur[j] = minInt(minInt(cur[j-1]+1, prev[j]+1), prev[j-1]+cost)
|
|
}
|
|
prev, cur = cur, prev
|
|
}
|
|
return prev[n]
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|