Skip to content

Commit 64b2e06

Browse files
authored
QR data extraction (kevoreilly#2480)
* Update screenshots.py * Add QR code URL extraction to screenshots module Introduces optional QR code URL extraction in Windows screenshots. Adds configuration option 'screenshots_qr' and corresponding UI checkbox, enabling automatic detection and opening of URLs from QR codes found in screenshots if OpenCV is available. * Add QR code URL extraction from screenshots Introduces QR code detection using OpenCV in the deduplication process. Extracted URLs from QR codes in screenshots are collected and stored in results. Adds a new signature module to report these URLs. * Update screenshots.py * Update deduplication.py * Update test_analysis_manager.py * Update test_analysis_manager.py * Update test_analysis_manager.py * Update test_analysis_manager.py
1 parent df66030 commit 64b2e06

File tree

7 files changed

+142
-65
lines changed

7 files changed

+142
-65
lines changed

analyzer/windows/modules/auxiliary/screenshots.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,29 @@
33
# See the file 'docs/LICENSE' for copying permission.
44

55
import logging
6+
import os
67
import time
8+
from contextlib import suppress
79
from io import BytesIO
810
from threading import Thread
911

12+
try:
13+
from PIL import Image
14+
except ImportError:
15+
pass
16+
1017
from lib.api.screenshot import Screenshot
1118
from lib.common.abstracts import Auxiliary
1219
from lib.common.results import NetlogFile
1320

21+
HAVE_CV2 = False
22+
with suppress(ImportError):
23+
import cv2
24+
import numpy as np
25+
26+
HAVE_CV2 = True
27+
28+
1429
log = logging.getLogger(__name__)
1530

1631
SHOT_DELAY = 1
@@ -20,13 +35,34 @@
2035
SKIP_AREA = None
2136

2237

38+
def handle_qr_codes(image_data):
39+
"""Extract URL from QR code if present."""
40+
if not HAVE_CV2:
41+
return None
42+
43+
try:
44+
image = Image.open(image_data)
45+
# Convert PIL image to BGR numpy array for OpenCV
46+
img = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR)
47+
detector = cv2.QRCodeDetector()
48+
extracted, _, _ = detector.detectAndDecode(img)
49+
# Simple URL detection
50+
if extracted and "://" in extracted[:10]:
51+
return extracted
52+
except Exception as e:
53+
log.debug("Error in handle_qr_codes: %s", e)
54+
55+
return None
56+
57+
2358
class Screenshots(Auxiliary, Thread):
2459
"""Take screenshots."""
2560

2661
def __init__(self, options, config):
2762
Auxiliary.__init__(self, options, config)
2863
Thread.__init__(self)
2964
self.enabled = config.screenshots_windows
65+
self.screenshots_qr = getattr(config, "screenshots_qr", False)
3066
self.do_run = self.enabled
3167

3268
def stop(self):
@@ -62,7 +98,17 @@ def run(self):
6298
img_current.save(tmpio, format="JPEG")
6399
tmpio.seek(0)
64100

65-
# now upload to host from the StringIO
101+
if self.screenshots_qr and HAVE_CV2:
102+
url = handle_qr_codes(tmpio)
103+
if url:
104+
log.info("QR code detected with URL: %s", url)
105+
try:
106+
# os.startfile is Windows only and usually works for URLs
107+
os.startfile(url)
108+
except Exception as e:
109+
log.error("Failed to open QR URL: %s", e)
110+
tmpio.seek(0)
111+
66112
nf = NetlogFile()
67113
nf.init(f"shots/{str(img_counter).rjust(4, '0')}.jpg")
68114
for chunk in tmpio:

conf/default/auxiliary.conf.default

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ procmon = no
3232
recentfiles = no
3333
screenshots_windows = yes
3434
screenshots_linux = yes
35+
screenshots_qr = no
3536
sysmon_windows = no
3637
sysmon_linux = no
3738
tlsdump = yes

modules/processing/deduplication.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@
1717
except ImportError:
1818
log.error("Missed dependency: poetry run pip install ImageHash")
1919

20+
HAVE_CV2 = False
21+
try:
22+
import cv2
23+
24+
HAVE_CV2 = True
25+
except ImportError:
26+
print("Missed dependency: poetry run pip install opencv-python")
27+
2028
try:
2129
from PIL import Image
2230

@@ -38,6 +46,23 @@ def reindex_screenshots(shots_path):
3846
os.rename(old_path, new_path)
3947

4048

49+
def handle_qr_codes(image_path):
50+
if not HAVE_CV2:
51+
return None
52+
try:
53+
# cv2.imread handles file path directly
54+
img = cv2.imread(image_path)
55+
if img is None:
56+
return None
57+
detector = cv2.QRCodeDetector()
58+
extracted, points, straight_qrcode = detector.detectAndDecode(img)
59+
if extracted and "://" in extracted[:10]:
60+
return extracted
61+
except Exception as e:
62+
log.error("Error detecting QR in %s: %s", image_path, e)
63+
return None
64+
65+
4166
class Deduplicate(Processing):
4267
"""Deduplicate screenshots."""
4368

@@ -116,6 +141,18 @@ def hashfunc(img):
116141
screenshots = sorted(self.deduplicate_images(userpath=shots_path, hashfunc=hashfunc))
117142
shots = [re.sub(r"\.(png|jpg)$", "", screenshot) for screenshot in screenshots]
118143

144+
if HAVE_CV2:
145+
qr_urls = set()
146+
for img_name in os.listdir(shots_path):
147+
if not img_name.lower().endswith((".jpg", ".png")):
148+
continue
149+
url = handle_qr_codes(os.path.join(shots_path, img_name))
150+
if url:
151+
qr_urls.add(url)
152+
153+
if qr_urls:
154+
self.results["qr_urls"] = list(qr_urls)
155+
119156
except Exception as e:
120157
log.error(e)
121158

modules/signatures/qr_urls.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
2+
from lib.cuckoo.common.abstracts import Signature
3+
4+
class QRUrls(Signature):
5+
name = "qr_urls"
6+
description = "URLs extracted from QR codes in screenshots"
7+
severity = 1
8+
categories = ["info"]
9+
authors = ["DoomedRaven"]
10+
minimum = "1.3"
11+
evented = False
12+
13+
def run(self):
14+
qr_urls = self.results.get("qr_urls")
15+
if qr_urls:
16+
for url in qr_urls:
17+
self.data.append({"url": url})
18+
return True
19+
return False

tests/test_analysis_manager.py

Lines changed: 26 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from sqlalchemy import select
1111

1212
from lib.cuckoo.common.abstracts import Machinery
13-
from lib.cuckoo.common.config import ConfigMeta
13+
from lib.cuckoo.common.config import Config, ConfigMeta
1414
from lib.cuckoo.core.analysis_manager import AnalysisManager
1515
from lib.cuckoo.core.database import TASK_RUNNING, Guest, Machine, Task, _Database
1616
from lib.cuckoo.core.machinery_manager import MachineryManager
@@ -303,7 +303,12 @@ def screenshot(self2, label, path):
303303
assert "no machine is used" in caplog.text
304304

305305
def test_build_options(
306-
self, db: _Database, tmp_path: pathlib.Path, task: Task, machine: Machine, machinery_manager: MachineryManager
306+
self,
307+
db: _Database,
308+
tmp_path: pathlib.Path,
309+
task: Task,
310+
machine: Machine,
311+
machinery_manager: MachineryManager,
307312
):
308313
with db.session.begin():
309314
task = db.session.merge(task)
@@ -315,57 +320,38 @@ def test_build_options(
315320

316321
analysis_man = AnalysisManager(task=task, machine=machine, machinery_manager=machinery_manager)
317322
opts = analysis_man.build_options()
318-
assert opts == {
319-
"amsi": False,
320-
"browser": True,
321-
"browsermonitor": False,
323+
324+
expected_opts = {
322325
"category": "file",
323326
"clock": datetime.datetime(2099, 1, 1, 9, 1, 1),
324-
"curtain": False,
325-
"digisig": True,
326-
"disguise": True,
327327
"do_upload_max_size": 0,
328-
"during_script": False,
329328
"enable_trim": 0,
330329
"enforce_timeout": 1,
331-
"evtx": False,
332330
"exports": "",
333-
"filecollector": True,
334331
"file_name": "sample.py",
335-
"file_pickup": False,
336332
"file_type": "Python script, ASCII text executable",
337-
"human_linux": False,
338-
"human_windows": True,
339333
"id": task.id,
340334
"ip": "5.6.7.8",
341335
"options": "foo=bar",
342336
"package": "foo",
343-
"permissions": False,
344337
"port": "2043",
345-
"pre_script": False,
346-
"procmon": False,
347-
"recentfiles": False,
348-
"screenshots_linux": True,
349-
"screenshots_windows": True,
350-
"sslkeylogfile": False,
351-
"sysmon_linux": False,
352-
"sysmon_windows": False,
353338
"target": str(tmp_path / "sample.py"),
354339
"terminate_processes": False,
355340
"timeout": 10,
356-
"tlsdump": True,
357-
"tracee_linux": False,
358341
"upload_max_size": 100000000,
359-
"usage": False,
360-
"windows_static_route": False,
361-
"windows_static_route_gateway": "192.168.1.1",
362-
"dns_etw": False,
363-
"wmi_etw": False,
364-
"watchdownloads": False,
365342
}
343+
# Dynamically load auxiliary modules from Config to ensure test stays in sync with configuration changes
344+
expected_opts.update(Config("auxiliary").auxiliary_modules)
345+
346+
assert opts == expected_opts
366347

367348
def test_build_options_pe(
368-
self, db: _Database, tmp_path: pathlib.Path, task: Task, machine: Machine, machinery_manager: MachineryManager
349+
self,
350+
db: _Database,
351+
tmp_path: pathlib.Path,
352+
task: Task,
353+
machine: Machine,
354+
machinery_manager: MachineryManager,
369355
):
370356
sample_location = get_test_object_path(
371357
pathlib.Path("data/core/5dd87d3d6b9d8b4016e3c36b189234772661e690c21371f1eb8e018f0f0dec2b")
@@ -380,54 +366,31 @@ def test_build_options_pe(
380366

381367
analysis_man = AnalysisManager(task=task, machine=machine, machinery_manager=machinery_manager)
382368
opts = analysis_man.build_options()
383-
assert opts == {
384-
"amsi": False,
385-
"browser": True,
386-
"browsermonitor": False,
369+
370+
expected_opts = {
387371
"category": "file",
388372
"clock": datetime.datetime(2099, 1, 1, 9, 1, 1),
389-
"curtain": False,
390-
"digisig": True,
391-
"disguise": True,
392373
"do_upload_max_size": 0,
393-
"during_script": False,
394374
"enable_trim": 0,
395375
"enforce_timeout": 1,
396-
"evtx": False,
397376
"exports": "",
398-
"filecollector": True,
399377
"file_name": sample_location.name,
400-
"file_pickup": False,
401378
"file_type": "PE32 executable (console) Intel 80386, for MS Windows",
402-
"human_linux": False,
403-
"human_windows": True,
404379
"id": task.id,
405380
"ip": "5.6.7.8",
406381
"options": "",
407382
"package": "file",
408-
"permissions": False,
409383
"port": "2043",
410-
"pre_script": False,
411-
"procmon": False,
412-
"recentfiles": False,
413-
"screenshots_linux": True,
414-
"screenshots_windows": True,
415-
"sslkeylogfile": False,
416-
"sysmon_linux": False,
417-
"sysmon_windows": False,
418384
"target": str(sample_location),
419385
"terminate_processes": False,
420386
"timeout": 10,
421-
"tlsdump": True,
422-
"tracee_linux": False,
423387
"upload_max_size": 100000000,
424-
"usage": False,
425-
"windows_static_route": False,
426-
"windows_static_route_gateway": "192.168.1.1",
427-
"dns_etw": False,
428-
"wmi_etw": False,
429-
"watchdownloads": False,
430388
}
389+
# Dynamically load auxiliary modules from Config to ensure test stays in sync with configuration changes
390+
expected_opts.update(Config("auxiliary").auxiliary_modules)
391+
392+
assert opts == expected_opts
393+
431394

432395
def test_category_checks(
433396
self, db: _Database, task: Task, machine: Machine, machinery_manager: MachineryManager, mocker: MockerFixture

web/submission/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,9 @@ def index(request, task_id=None, resubmit_hash=None):
352352
if request.POST.get("unpack"):
353353
options += "unpack=yes,"
354354

355+
if request.POST.get("screenshots_qr"):
356+
options += "screenshots_qr=yes,"
357+
355358
job_category = False
356359
if request.POST.get("job_category"):
357360
job_category = request.POST.get("job_category")

web/templates/submission/index.html

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,15 @@ <h5 class="mb-0 text-white"><i class="fas fa-cogs me-2 text-primary"></i>Advance
598598
id="duringScript" name="during_script">
599599
</div>
600600
{% endif %}
601-
601+
<div class="form-check">
602+
<label>
603+
<input type="checkbox" name="screenshots_qr" /> QR URL extraction <span class="text-muted"><small>(Extract and open URLs from QR codes in screenshots)</small></span>
604+
</label>
605+
</div>
606+
<div class="form-check">
607+
<label>
608+
<input type="checkbox" name="static"/> Try to extract config without VM <span class="text-muted"><small>(Submit to VM if not extracted)</small></span>
609+
</label>
602610
<div class="mb-3">
603611
<label for="form_custom" class="text-white-50">Custom Field</label>
604612
<input type="text" class="form-control bg-dark text-white border-secondary"

0 commit comments

Comments
 (0)