Skip to content

Commit 9b2a9b8

Browse files
authored
Merge pull request abrignoni#669 from mobasi-team/security/nq-vault-path-traversal-fix
security: block NQ Vault path traversal on decrypted file writes
2 parents 5e7fae8 + 4924c15 commit 9b2a9b8

2 files changed

Lines changed: 125 additions & 47 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import os
2+
import sys
3+
import tempfile
4+
import unittest
5+
from pathlib import Path
6+
import uuid
7+
8+
9+
ROOT_DIR = os.path.dirname(
10+
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11+
)
12+
if ROOT_DIR not in sys.path:
13+
sys.path.insert(0, ROOT_DIR)
14+
15+
from scripts.artifacts import NQ_Vault
16+
import scripts.ilapfuncs as ilapfuncs
17+
18+
19+
class TestNQVaultPathSecurity(unittest.TestCase):
20+
def test_sanitize_output_filename_removes_traversal(self):
21+
sanitized = NQ_Vault.sanitize_output_filename('../../../Users/examiner/.zshrc')
22+
self.assertEqual(sanitized, 'zshrc')
23+
24+
def test_build_safe_output_path_is_confined_to_report_folder(self):
25+
with tempfile.TemporaryDirectory() as tmpdir:
26+
report_folder = Path(tmpdir) / 'report'
27+
report_folder.mkdir()
28+
safe_path = NQ_Vault.build_safe_output_path(
29+
str(report_folder),
30+
'../../../../../../outside_written.bin',
31+
)
32+
self.assertTrue(str(safe_path).startswith(str(report_folder.resolve())))
33+
self.assertEqual(safe_path.name, 'outside_written.bin')
34+
35+
def test_file_decryption_blocks_path_traversal_write(self):
36+
with tempfile.TemporaryDirectory() as tmpdir:
37+
base = Path(tmpdir)
38+
report_folder = base / 'report'
39+
report_folder.mkdir()
40+
ilapfuncs.OutputParameters.screen_output_file_path = str(base / 'screen_output.html')
41+
42+
image_dir = base / 'payload' / '.image'
43+
image_dir.mkdir(parents=True)
44+
encrypted_file = image_dir / '1700000000.bin'
45+
encrypted_file.write_bytes(b'A' * 200)
46+
47+
unique_name = f'outside_written_{uuid.uuid4().hex}.bin'
48+
traversal_name = f'../../../{unique_name}'
49+
file_info = {
50+
'1700000000.bin': {
51+
'old_filepath': '/sdcard/DCIM/Camera/original.jpg',
52+
'old_filename': traversal_name,
53+
'vault_filepath': '/sdcard/SystemAndroid/Data/hash/.image/1700000000.bin',
54+
'timestamp': '2024-01-01 00:00:00',
55+
'vid_length': '',
56+
'resolution': '',
57+
'alb_name': 'Default',
58+
'prev_alb_name': '',
59+
'password_id': 'enc-pin-id',
60+
}
61+
}
62+
pin_info = {
63+
'enc-pin-id': {
64+
'xor_key': '0x00',
65+
'pin_for_XOR_key': '0000',
66+
}
67+
}
68+
69+
NQ_Vault.file_decryption([str(encrypted_file)], file_info, pin_info, str(report_folder))
70+
71+
outside_target = (report_folder / traversal_name).resolve()
72+
self.assertFalse(outside_target.exists())
73+
self.assertTrue((report_folder / unique_name).exists())
74+
75+
76+
if __name__ == '__main__':
77+
unittest.main()

scripts/artifacts/NQ_Vault.py

Lines changed: 48 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,44 @@
11
import itertools
2+
import re
23
import string
4+
import sqlite3
35
from pathlib import Path
4-
from os.path import join
56

67
from scripts.artifact_report import ArtifactHtmlReport
7-
from scripts.ilapfuncs import logfunc, tsv, timeline, is_platform_windows, media_to_html, open_sqlite_db_readonly
8+
from scripts.ilapfuncs import logfunc, tsv, media_to_html, open_sqlite_db_readonly
89

910

10-
'''def extract_PIN_from_db(file_found):
11-
try:
12-
connection = open_sqlite_db_readonly(file_found)
13-
14-
set_pointer = connection.cursor()
15-
set_pointer.execute("SELECT passwd_temp FROM albumstemp")
11+
def sanitize_output_filename(filename, default_name='recovered_file.bin'):
12+
raw_name = str(filename or '')
13+
normalized = raw_name.replace('\\', '/')
14+
safe_name = Path(normalized).name
15+
safe_name = re.sub(r'[\x00-\x1f<>:"/\\|?*]+', '_', safe_name)
16+
safe_name = safe_name.strip().strip('.')
17+
if not safe_name:
18+
return default_name
19+
return safe_name
1620

17-
encrypted_password = set_pointer.fetchone()[0]
1821

19-
return encrypted_password, file_found
20-
except:
21-
return None'''
22+
def build_safe_output_path(report_folder, original_filename):
23+
report_root = Path(report_folder).resolve()
24+
sanitized_name = sanitize_output_filename(original_filename)
25+
output_path = (report_root / sanitized_name).resolve()
26+
try:
27+
output_path.relative_to(report_root)
28+
except ValueError:
29+
output_path = report_root / sanitize_output_filename(None)
30+
31+
if output_path.exists():
32+
stem = output_path.stem
33+
suffix = output_path.suffix
34+
index = 1
35+
while True:
36+
candidate = report_root / f'{stem}_{index}{suffix}'
37+
if not candidate.exists():
38+
output_path = candidate
39+
break
40+
index += 1
41+
return output_path
2242

2343

2444
def extract_data_from_db(file_found):
@@ -63,7 +83,7 @@ def extract_data_from_db(file_found):
6383
dict_of_dicts[encrypted_filename] = new_dict
6484

6585
return dict_of_dicts, list_of_enc_pins, file_found
66-
except:
86+
except (sqlite3.Error, OSError, ValueError, TypeError, AttributeError, IndexError, KeyError):
6787
return None
6888

6989

@@ -95,8 +115,7 @@ def brute_force_pin(encoded_PIN):
95115
if decoded_test_value_string == encoded_PIN:
96116
#logfunc(f'Decrypted PIN is: {pin}')
97117
return pin
98-
else:
99-
pin_len += 1
118+
pin_len += 1
100119

101120
if decoded_PIN is None:
102121
print('Sorry. No PIN found.')
@@ -120,30 +139,6 @@ def raw_pin_to_XOR_key(pin):
120139

121140
return hex_XOR_key
122141

123-
124-
'''def read_file_info(file_found):
125-
file_match_dict = {}
126-
print(f'readfileinfo: {file_found}')
127-
connection = open_sqlite_db_readonly(file_found)
128-
129-
set_pointer = connection.cursor()
130-
set_pointer.execute("SELECT file_name_from, file_path_new FROM hideimagevideo")
131-
132-
required_column = set_pointer.fetchall()
133-
134-
if len(required_column) < 1:
135-
logfunc('No encrypted media present')
136-
return
137-
else:
138-
for filename in required_column:
139-
decrypted_filename = filename[0]
140-
encrypted_filename = filename[1].split('/')[-1]
141-
142-
file_match_dict[decrypted_filename] = encrypted_filename
143-
144-
return file_match_dict'''
145-
146-
147142
def file_decryption(files_found, dict_of_file_info, dict_of_pin_dicts, report_folder):
148143
data_list = []
149144
logfunc('Encrypted files found! File Decryption in progress...')
@@ -187,12 +182,13 @@ def file_decryption(files_found, dict_of_file_info, dict_of_pin_dicts, report_fo
187182
byte = file_to_decrypt.read(1)
188183

189184
xord_bytes_decrypted = bytes(xor_list)
190-
with open(join(report_folder, decrypted_file_name), 'wb') as decryptedFile:
185+
decrypted_output_path = build_safe_output_path(report_folder, decrypted_file_name)
186+
with open(decrypted_output_path, 'wb') as decryptedFile:
191187
decryptedFile.write(xord_bytes_decrypted)
192188
decryptedFile.close()
193189

194190
tolink = []
195-
pathdec = join(report_folder, decrypted_file_name)
191+
pathdec = str(decrypted_output_path)
196192
tolink.append(pathdec)
197193
thumb = media_to_html(pathdec, tolink, report_folder)
198194

@@ -213,24 +209,28 @@ def file_decryption(files_found, dict_of_file_info, dict_of_pin_dicts, report_fo
213209
html_no_escape=['Media'])
214210
report.end_artifact_report()
215211

216-
tsvname = f'NQVault'
212+
tsvname = 'NQVault'
217213
tsv(report_folder, data_headers, data_list, tsvname)
218214

219215

220216
# MAIN #
221217
def get_NQVault(files_found, report_folder, seeker, wrap_text):
218+
_ = seeker, wrap_text
222219
data_list = []
223220
list_of_enc_pins = []
224221
dict_of_file_info = {}
225222
dict_of_pin_dicts = {}
226-
sucess = 0
223+
file_found_pin = ''
224+
success = 0
227225
# Get the "encrypted" PIN from DB
228226
for file_found in files_found:
229227
if file_found.endswith('322w465ay423xy11'):
230228
# multiple password hashes may be present, so these are stored as a list
231-
dict_of_file_info, list_of_enc_pins, file_found_pin = extract_data_from_db(file_found)
232-
sucess = 1
233-
if sucess == 0:
229+
extraction_result = extract_data_from_db(file_found)
230+
if extraction_result:
231+
dict_of_file_info, list_of_enc_pins, file_found_pin = extraction_result
232+
success = 1
233+
if success == 0:
234234
logfunc('No Database DB Found or no hashed PIN present.')
235235
return
236236

@@ -256,7 +256,8 @@ def get_NQVault(files_found, report_folder, seeker, wrap_text):
256256
report.add_script()
257257
data_headers = ('Encrypted PIN', 'Decrypted PIN')
258258

259-
report.write_artifact_data_table(data_headers, data_list, file_found_pin, html_no_escape=['Media'])
259+
report_source = file_found_pin if file_found_pin else 'NQ Vault Source DB'
260+
report.write_artifact_data_table(data_headers, data_list, report_source, html_no_escape=['Media'])
260261
report.end_artifact_report()
261262

262263
# Media decryption funct

0 commit comments

Comments
 (0)