
바이너리의 함수를 바이트 형태가 아닌 동작 방식으로 매칭합니다. 마이크로실행을 통한 동작 기반 함수 핑거프린팅.
fnprint는 바이너리의 함수를 바이트나 제어 흐름 그래프의 모양이 아니라 함수가 하는 일을 기준으로 매칭합니다. 각 함수를 가짜 입력을 넣은 작은 에뮬레이터에서 실행하고, 발생하는 부수 효과를 기록한 다음, 그 동작을 해시하여 지문으로 만듭니다. 동일하게 동작하는 두 함수는 다른 컴파일러나 다른 최적화 수준으로 빌드되었더라도 유사한 지문을 얻습니다.
이렇게 하는 이유는 다음과 같습니다. 바이트 시그니처(FLIRT, FunctionID)는 코드가 재컴파일되는 순간 깨지고, CFG 매처(BinDiff, Diaphora)는 -O0와 -O3 사이에서 불안정해집니다. 동작(behavior)은 두 경우 모두 훨씬 잘 버텨냅니다.
지금은 x86-64 ELF만 지원합니다. 믿고 쓰기 전에 한계를 확인하세요.
스트리핑된 바이너리와 이미 이름을 알고 있는 것들의 코퍼스를 지정해 보세요:
$ strip --strip-all mystery.so
$ nm mystery.so
nm: mystery.so: no symbols
$ fnprint index libz.so -o corpus.db # a build you have symbols for
$ fnprint query mystery.so --corpus corpus.db
named 9 function(s):
0x000022f9 100.0% adler32_z
0x00002a8d 100.0% compress2
0x0000ad5d 100.0% inflateBackEnd
0x0000adc4 86.7% inflate_fast
0x00002e67 80.5% crc32_z
...
이 실행은 완전히 스트리핑된 -O0 빌드를 -O2 코퍼스로 이름 붙인 것입니다. 최적화 수준이 다르고 심볼이 하나도 남아 있지 않은데도 이름이 올바르게 돌아옵니다. 자신 있는 것만 이름을 붙이고 나머지는 조용히 넘어갑니다.
또 다른 기능은 두 빌드를 diff하여 어떤 함수의 동작이 바뀌었는지 알려주는 것입니다. 벤더가 새 펌웨어를 배포했을 때 실제로 무엇이 변경되었는지 알고 싶을 때 유용합니다:
$ fnprint match old.so new.so
compared 84 functions present in both
unchanged: 53
changed: 1
low-signal: 31 (too small to judge)
changed behavior (lowest similarity first):
61.7% deflate_stored
실제 n-day 작업에는 triage가 있습니다. 함수의 알려진 취약 버전에서 코퍼스 하나를, 패치된 버전에서 다른 하나를 만든 다음, 알 수 없는 빌드를 양쪽 모두에 대해 순위를 매깁니다. 취약한 쪽에 가깝고 패치된 쪽과 명확히 분리되는 함수가 사람 앞에 놓여야 할 대상입니다. 해석해야 하는 단일 매치 점수가 아니라요:
$ fnprint index vuln.so -o vuln.db
$ fnprint index patched.so -o patched.db
$ fnprint triage mystery.so --vuln vuln.db --patched patched.db
43 functions triaged: 1 look vulnerable, 0 patched, 42 inconclusive
review queue (vuln-leaning, strongest first):
addr vuln% patched% margin matches
0x000022f9 100.0 27.3 +72.7 adler32_z vs crc32_z
두 버전에서 동일한 42개 함수는 의도적으로 판정 불가(inconclusive)로 돌아옵니다. 어느 쪽에도 확정할 수 없으므로 플래그로 표시하면 안 됩니다. --margin과 --min-sim은 판정을 내리기 전에 양쪽이 얼마나 분리되어야 하는지를 제어합니다.
각 함수에 대해:
학습 데이터도 모델도 없습니다. 같은 아이디어는 문헌에서 Blanket Execution(Egele et al, USENIX Security 2014)으로 등장합니다. fnprint는 이를 실제로 사용할 수 있는 CLI와 함께 제공되는 실용적이고 유지 관리되는 구현입니다.
rust 툴체인과 unicorn + capstone 라이브러리가 필요합니다.
# debian/ubuntu/kali
sudo apt install libunicorn-dev libcapstone-dev
cargo install --path cli
# or just
cargo build --release # binary at target/release/fnprint
fnprint index <binary> [-o out.db] fingerprint every function, optionally to a db
fnprint match <a> <b> diff two binaries (or .db files) by behavior
fnprint query <target> --corpus <db> name unknown functions from a corpus
fnprint triage <t> --vuln <db> --patched <db> rank a build against vuln vs patched corpora
fnprint eval <a> <b> accuracy metrics using symbol names as truth
fnprint dump <binary> <func> print the recorded effect trace (debugging)
match와 query는 ELF 또는 index로 만든 .db를 입력으로 받습니다. 따라서 코퍼스를 한 번 지문화해 두고 재사용할 수 있습니다.
rank-1 정확도란 빌드 A의 함수에 대해 빌드 B의 모든 함수를 유사도 순으로 정렬했을 때 최상위 결과가 올바른 함수인지의 비율입니다. 이것은 정확히 스트리핑된 바이너리의 이름 붙이기 작업입니다. zlib 1.3.1(84개 함수)로 측정했으며, bench/run.sh로 재현할 수 있습니다:
전체 표와 두 번째 라이브러리(lua)는 bench/NUMBERS.md에 있습니다.
eval은 또한 recall@3 / recall@5와 기권률(abstention rate)을 보고합니다. rank-1만으로는 숨겨지는 것이 많기 때문입니다. gcc O0 -> O2의 경우 최상위 결과가 93% 확률로 맞지만 올바른 함수가 상위 5개 안에 들 확률은 96.6%입니다. 따라서 작은 검토 예산만으로도 대부분의 격차를 메울 수 있습니다. 또한 확신이 없는 쌍에 대해서는 추측 대신 기권합니다(자신 있는 "같음" 판정을 거부). 그래서 같은 임계값에서 recall은 낮아도 precision은 높게 유지됩니다.
솔직한 평가는 이렇습니다. 적어도 한쪽에 행동적 풍부함이 있으면(-O0/-O1 빌드 또는 -O0의 크로스 컴파일러 쌍) 80-98% 범위에 들어갑니다. 양쪽 모두 과도하게 최적화된 경우 관찰할 수 있는 동작이 얇아져 동전 던지기 수준으로 떨어집니다. 이것이 단일 패스, 학습 없는 매처에게는 어려운 경계이며, 이 도구도 그렇지 않은 척 하지 않습니다.
match에 나타나지 않습니다. 구조적 변경과 초기 경로 변경은 잡아내지만 모든 깊은 변경까지 잡지는 못합니다.fnprint는 학습이 필요 없고 동작 우선(behavior-first) 방식입니다. 효과 모델은 이미 아키텍처 중립적이며, 이는 CPU 간 매칭을 위한 기초 작업입니다.
explore_depth, 기본값은 꺼짐) 도달 불가능한 경로에서 빌드별 노이즈를 추가하고 테스트에서 크로스 빌드 정확도를 떨어뜨렸습니다. 따라서 쓸모를 인정받으려면 경로 일관성 필터가 필요합니다.MIT. LICENSE를 참조하세요.
| 쌍 | rank-1 | 정밀도 |
|---|
| gcc O0 -> O1 | 97.1% | 93.8% |
| gcc O0 -> O2 | 93.1% | 83.3% |
| gcc O0 -> O3 | 91.3% | 100.0% |
| gcc/clang O0 | 97.7% | 97.1% |
| gcc O2 -> O3 | 56.5% | 66.7% |
| gcc/clang O2 | 59.1% | 50.0% |