5465 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / app.js JS
const express = require('express');
const { EntitySchema, Type, raw } = require('@mikro-orm/core');
const { MikroORM } = require('@mikro-orm/sqlite');

const PORT = Number(process.env.PORT || 3000);

class Post {}
class Salary {}

// Type을 직접 상속한 커스텀 타입 (취약점 발생 지점)
class JsonOrRawType extends Type {
  convertToDatabaseValue(value) {
    if (value && typeof value === 'object' && '__raw' in value) {
      return value;
    }
    return typeof value === 'string' ? value : String(value ?? '');
  }
  convertToJSValue(value) {
    if (typeof value === 'string') {
      try { return JSON.parse(value); } catch { return value; }
    }
    return value;
  }
  getColumnType() {
    return 'text';
  }
  get runtimeType() {
    return 'string';
  }
}

const PostSchema = new EntitySchema({
  class: Post,
  tableName: 'posts',
  properties: {
    id:         { type: 'number', primary: true, autoincrement: true },
    author:     { type: 'string' },
    title:      { type: 'string' },
    content:    { type: JsonOrRawType, trackChanges: false },  // 취약 컬럼
    created_at: { type: 'string' },
  },
});

const SalarySchema = new EntitySchema({
  class: Salary,
  tableName: 'salaries',
  properties: {
    id:     { type: 'number', primary: true, autoincrement: true },
    name:   { type: 'string' },
    salary: { type: 'number' },
  },
});

const seedSalaries = [
  { name: 'Mia Bailey',        salary: 156000 },
  { name: 'Emma Carter',       salary: 168000 },
  { name: 'Ava Hughes',        salary: 142000 },
  { name: 'Noah Sullivan',     salary: 134000 },
  { name: 'Liam Parker',       salary: 128000 },
  { name: 'Daniel Long',       salary: 139000 },
  { name: 'Grace Simmons',     salary: 145000 },
  { name: 'Logan Russell',     salary: 172000 },
  { name: 'Alexander Hayes',   salary: 161000 },
  { name: 'William Jenkins',   salary: 158000 },
  { name: 'Scarlett Hamilton', salary: 183000 },
  { name: 'Sophia Ward',       salary: 149000 },
  { name: 'Harper Gray',       salary: 122000 },
  { name: 'Mason Powell',      salary: 118000 },
  { name: 'Ethan Coleman',     salary: 115000 },
  { name: 'FLAG = EQST{r4w_qu3ry_fr4gm3nt_1nj3ct10n}', salary: 299299299 },
];

let orm;
let activeQueryLogScope = null;

function escHtml(str) {
  return String(str || '')
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

async function withQueryLogging(scope, handler) {
  activeQueryLogScope = scope;
  try { return await handler(); }
  finally { activeQueryLogScope = null; }
}

async function initOrm() {
  return MikroORM.init({
    entities: [PostSchema, SalarySchema],
    dbName: process.env.DB_PATH || ':memory:',
    debug: true,
    logger: (message) => {
      const prefix = activeQueryLogScope ? `[SQL][${activeQueryLogScope}]` : '[SQL]';
      console.log(`${prefix} ${message}`);
    },
  });
}

async function seedDatabase(activeOrm) {
  await activeOrm.schema.refreshDatabase();
  const em = activeOrm.em.fork();
  const salaries = seedSalaries.map(s => em.create(Salary, s));
  em.persist(salaries);
  await em.flush();
}

function buildApp(activeOrm) {
  const app = express();
  app.use(express.json());

  // 메인 페이지 - 게시판
  app.get('/', async (req, res) => {
    const posts = await withQueryLogging('list-posts', async () => {
      const em = activeOrm.em.fork();
      return em.find(Post, {}, { orderBy: { id: 'desc' } });
    });

    res.type('html').send(`<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>사내 게시판</title>
  <style>
    :root { --bg:#f6f3ed; --panel:rgba(255,252,248,0.92); --ink:#1b1a17; --muted:#6b665e; --accent:#9a3f26; --border:rgba(120,102,80,0.18); --shadow:0 24px 80px rgba(53,36,12,0.08); }
    * { box-sizing:border-box; }
    body { margin:0; min-height:100vh; color:var(--ink); font-family:"Segoe UI","Helvetica Neue",Arial,sans-serif; background:radial-gradient(circle at top left,rgba(154,63,38,0.12),transparent 30%),radial-gradient(circle at bottom right,rgba(109,122,90,0.09),transparent 34%),linear-gradient(180deg,#fbf8f2 0%,var(--bg) 100%); }
    .shell { max-width:860px; margin:0 auto; padding:48px 16px 72px; }
    .card { background:var(--panel); backdrop-filter:blur(12px); border:1px solid var(--border); border-radius:24px; box-shadow:var(--shadow); }
    .header { padding:24px 28px; margin-bottom:20px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px; }
    .title { margin:0; font-size:1.8rem; font-weight:700; letter-spacing:0.1em; text-transform:uppercase; color:var(--muted); }
    .subtitle { font-size:0.85rem; color:var(--muted); font-weight:600; letter-spacing:0.14em; text-transform:uppercase; }
    a.write-btn { text-decoration:none; padding:12px 22px; border-radius:999px; color:#fffaf4; background:linear-gradient(135deg,#8f2a16,#c3562f); font-weight:700; font-size:0.95rem; }
    .posts { padding:8px 16px 24px; }
    .post-item { padding:20px 12px; border-bottom:1px solid var(--border); }
    .post-item:last-child { border-bottom:none; }
    .post-title { font-size:1.05rem; font-weight:700; margin:0 0 6px; }
    .post-meta { font-size:0.85rem; color:var(--muted); margin-bottom:8px; display:flex; gap:12px; align-items:center; }
    .post-content { font-size:0.95rem; color:var(--ink); white-space:pre-wrap; word-break:break-all; }
    .edit-btn { text-decoration:none; font-size:0.8rem; padding:4px 10px; border-radius:999px; border:1px solid var(--border); color:var(--muted); background:rgba(255,255,255,0.8); }
    .empty { padding:40px; text-align:center; color:var(--muted); }
  </style>
</head>
<body>
  <main class="shell">
    <section class="card">
      <div class="header">
        <div>
          <div class="title">사내 게시판</div>
          <div class="subtitle">EQST Internal Board</div>
        </div>
        <a class="write-btn" href="/write">✏️ Write Post</a>
      </div>
      <div class="posts">
        ${posts.length === 0
          ? '<div class="empty">No posts yet. Be the first to write!</div>'
          : posts.map(p => `
            <div class="post-item">
              <div class="post-title">${escHtml(p.title)}</div>
              <div class="post-meta">
                <span>by ${escHtml(p.author)}</span>
                <span>${escHtml(p.created_at)}</span>
                <a class="edit-btn" href="/edit/${p.id}">Edit</a>
              </div>
              <div class="post-content">${escHtml(p.content)}</div>
            </div>
          `).join('')}
      </div>
    </section>
  </main>
</body>
</html>`);
  });

  // 글 작성 페이지
  app.get('/write', (_req, res) => {
    res.type('html').send(`<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Write Post</title>
  <style>
    :root { --panel:rgba(255,252,248,0.92); --muted:#6b665e; --accent:#9a3f26; --border:rgba(120,102,80,0.18); --shadow:0 24px 80px rgba(53,36,12,0.08); }
    * { box-sizing:border-box; }
    body { margin:0; min-height:100vh; display:grid; place-items:center; background:linear-gradient(180deg,#fbf8f2 0%,#f6f3ed 100%); font-family:"Segoe UI","Helvetica Neue",Arial,sans-serif; color:#1b1a17; }
    .card { width:min(560px,calc(100vw - 32px)); padding:32px; border-radius:24px; background:var(--panel); border:1px solid var(--border); box-shadow:var(--shadow); }
    h1 { margin:0 0 24px; font-size:1.4rem; }
    .row { display:grid; gap:14px; }
    label { font-weight:600; font-size:0.9rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.08em; }
    input, textarea { width:100%; padding:12px 14px; border-radius:14px; border:1px solid var(--border); font:inherit; background:rgba(255,255,255,0.9); }
    textarea { min-height:160px; resize:vertical; }
    button { width:100%; padding:14px; border:0; border-radius:14px; font:inherit; font-weight:700; color:#fffaf4; background:linear-gradient(135deg,#8f2a16,#c3562f); cursor:pointer; }
    #status { margin-top:12px; color:var(--muted); font-size:0.95rem; }
    a { color:var(--accent); }
  </style>
</head>
<body>
  <main class="card">
    <h1>✏️ Write Post</h1>
    <div class="row">
      <div><label>Author</label><input id="author" type="text" placeholder="Your name" /></div>
      <div><label>Title</label><input id="title" type="text" placeholder="Post title" /></div>
      <div><label>Content</label><textarea id="content" placeholder="Write your post here..."></textarea></div>
      <button id="submit">Post</button>
    </div>
    <div id="status"></div>
    <div style="margin-top:14px;"><a href="/">← Back to Board</a></div>
  </main>
  <script>
    document.getElementById('submit').addEventListener('click', async () => {
      const author  = document.getElementById('author').value.trim();
      const title   = document.getElementById('title').value.trim();
      const content = document.getElementById('content').value.trim();
      if (!author || !title || !content) {
        document.getElementById('status').textContent = 'Please fill in all fields.';
        return;
      }
      const res = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ author, title, content }),
      });
      const data = await res.json();
      if (data.ok) { window.location.href = '/'; }
      else { document.getElementById('status').textContent = data.error || 'Failed to post.'; }
    });
  </script>
</body>
</html>`);
  });

  // 글 수정 페이지
  app.get('/edit/:id', async (req, res) => {
    const post = await withQueryLogging('get-post', async () => {
      const em = activeOrm.em.fork();
      return em.findOne(Post, { id: Number(req.params.id) });
    });

    if (!post) { res.status(404).send('Not found'); return; }

    res.type('html').send(`<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Edit Post</title>
  <style>
    :root { --panel:rgba(255,252,248,0.92); --muted:#6b665e; --accent:#9a3f26; --border:rgba(120,102,80,0.18); --shadow:0 24px 80px rgba(53,36,12,0.08); }
    * { box-sizing:border-box; }
    body { margin:0; min-height:100vh; display:grid; place-items:center; background:linear-gradient(180deg,#fbf8f2 0%,#f6f3ed 100%); font-family:"Segoe UI","Helvetica Neue",Arial,sans-serif; color:#1b1a17; }
    .card { width:min(560px,calc(100vw - 32px)); padding:32px; border-radius:24px; background:var(--panel); border:1px solid var(--border); box-shadow:var(--shadow); }
    h1 { margin:0 0 24px; font-size:1.4rem; }
    .row { display:grid; gap:14px; }
    label { font-weight:600; font-size:0.9rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.08em; }
    input, textarea { width:100%; padding:12px 14px; border-radius:14px; border:1px solid var(--border); font:inherit; background:rgba(255,255,255,0.9); }
    textarea { min-height:160px; resize:vertical; }
    button { width:100%; padding:14px; border:0; border-radius:14px; font:inherit; font-weight:700; color:#fffaf4; background:linear-gradient(135deg,#8f2a16,#c3562f); cursor:pointer; }
    #status { margin-top:12px; color:var(--muted); font-size:0.95rem; }
    a { color:var(--accent); }
  </style>
</head>
<body>
  <main class="card">
    <h1>✏️ Edit Post</h1>
    <div class="row">
      <div><label>Title</label><input id="title" type="text" value="${escHtml(post.title)}" /></div>
      <div><label>Content</label><textarea id="content">${escHtml(post.content)}</textarea></div>
      <button id="submit">Save</button>
    </div>
    <div id="status"></div>
    <div style="margin-top:14px;"><a href="/">← Back to Board</a></div>
  </main>
  <script>
    document.getElementById('submit').addEventListener('click', async () => {
      const title   = document.getElementById('title').value.trim();
      const content = document.getElementById('content').value.trim();
      if (!title || !content) {
        document.getElementById('status').textContent = 'Please fill in all fields.';
        return;
      }
      const res = await fetch('/api/posts/${post.id}', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title, content }),
      });
      const data = await res.json();
      if (data.ok) { window.location.href = '/'; }
      else { document.getElementById('status').textContent = data.error || 'Failed to update.'; }
    });
  </script>
</body>
</html>`);
  });

  // 글 작성 API
  app.post('/api/posts', async (req, res) => {
    const { author, title, content } = req.body || {};
    if (!author || !title || !content) {
      res.status(400).json({ ok: false, error: 'author, title, content are required' });
      return;
    }
    try {
      await withQueryLogging('create-post', async () => {
        const em = activeOrm.em.fork();
        // 취약 지점: em.create + flush → ChangeSetPersister → nativeInsertMany → formatQuery → quoteValue
        const post = em.create(Post, {
          author,
          title,
          content,
          created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
        });
        await em.flush();
      });
      res.json({ ok: true });
    } catch (error) {
      res.status(500).json({ ok: false, error: error.message });
    }
  });

  // 글 수정 API (취약 지점: content가 nativeUpdate SET절로 직접 전달됨)
  app.put('/api/posts/:id', async (req, res) => {
    const { title, content } = req.body || {};
    const id = Number(req.params.id);
    if (!title || !content) {
      res.status(400).json({ ok: false, error: 'title, content are required' });
      return;
    }
    try {
      await withQueryLogging('update-post', async () => {
        const em = activeOrm.em.fork();
        await em.nativeUpdate(Post, { id }, { title, content });
      });
      res.json({ ok: true });
    } catch (error) {
      res.status(500).json({ ok: false, error: error.message });
    }
  });

  app.get('/healthz', async (_req, res) => {
    try {
      await activeOrm.em.getConnection().execute('select 1');
      res.json({ ok: true });
    } catch (error) {
      res.status(500).json({ ok: false, error: error.message });
    }
  });

  return app;
}

async function initialize() {
  if (orm) await orm.close(true);
  orm = await initOrm();
  await seedDatabase(orm);
  return buildApp(orm);
}

async function closeOrm() {
  if (orm) { await orm.close(true); orm = undefined; }
}

async function main() {
  const app = await initialize();
  app.listen(PORT, () => console.log(`Challenge server listening on http://127.0.0.1:${PORT}`));
}

if (require.main === module) {
  main().catch(async (error) => {
    console.error(error);
    await closeOrm();
    process.exit(1);
  });
}

module.exports = { Post, PORT, buildApp, closeOrm, initialize };