Summary

This write-up analyzes the Intigriti 0226 challenge and explains how unsafe Markdown rendering, a client-side script re-execution helper, and an exposed JSONP endpoint combine into a CSP bypass and admin session compromise.

Project structure

├── app
│   ├── app.py
│   ├── Dockerfile
│   ├── favicon.ico
│   ├── requirements.txt
│   ├── static
│   │   ├── css
│   │   │   ├── challenge-style.css
│   │   │   └── style.css
│   │   ├── images
│   │   │   ├── creator.jpg
│   │   │   ├── pattern.svg
│   │   │   └── share.png
│   │   └── js
│   │       └── preview.js
│   └── templates
│       ├── base.html
│       ├── challenge.html
│       ├── dashboard.html
│       ├── index.html
│       ├── login.html
│       ├── post_edit.html
│       ├── post_new.html
│       ├── post_view.html
│       └── register.html
├── bot
│   ├── bot.py
│   ├── Dockerfile
│   └── requirements.txt
├── docker-compose.yml
└── nginx
    ├── Dockerfile
    └── nginx.conf

It is a notes app with features like creating, editing, deleting posts, and a “report to admin” flow.


Markdown rendering is unsafe

In app.py, the server renders user content with a custom “Markdown” function:

def render_markdown(content):
    html_content = content
    html_content = re.sub(r'^### (.+)$', r'<h3>\\1</h3>', html_content, flags=re.MULTILINE)
    html_content = re.sub(r'^## (.+)$', r'<h2>\\1</h2>', html_content, flags=re.MULTILINE)
    html_content = re.sub(r'^# (.+)$', r'<h1>\\1</h1>', html_content, flags=re.MULTILINE)
    html_content = re.sub(r'\\*\\*(.+?)\\*\\*', r'<strong>\\1</strong>', html_content)
    html_content = re.sub(r'\\*(.+?)\\*', r'<em>\\1</em>', html_content)
    html_content = re.sub(r'\\[(.+?)\\]\\((.+?)\\)', r'<a href="\\2">\\1</a>', html_content)
    html_content = html_content.replace('\\n\\n', '</p><p>')
    html_content = f'<p>{html_content}</p>'
    return html_content

This does not escape HTML. So if a user includes something like <script>...</script> in a note, it will be returned as part of the rendered HTML.

image.png

The API endpoint that serves this rendered output is /api/render:

@app.route('/api/render')
def api_render():
    post_id = request.args.get('id')
    if not post_id:
        return jsonify({'error': 'Missing id'}), 400

    post = Post.query.get(post_id)
    if not post:
        return jsonify({'error': 'Not found'}), 404

    rendered_html = render_markdown(post.content)

    return jsonify({
        'id': post.id,
        'title': post.title,
        'html': rendered_html,
        'author': post.author.username,
        'rendered_at': time.time()
    })

So we have a clear HTML injection primitive in html.


Client-side code introduces an XSS sink

The post preview loads the rendered HTML and inserts it into the DOM using innerHTML:

static/js/preview.js

fetch('/api/render?id=' + postId)
    .then(function(response) {
        if (!response.ok) throw new Error('Failed to load');
        return response.json();
    })
    .then(function(data) {
        const preview = document.getElementById('preview');
        preview.innerHTML = data.html;
        processContent(preview);
    })
    .catch(function(error) {
        document.getElementById('preview').innerHTML = '<p class="error">Failed to load content.</p>';
    });

At this point it looks like classic XSS, but there are two important caveats: